diff --git a/.changeset/angry-dryers-hang.md b/.changeset/angry-dryers-hang.md new file mode 100644 index 000000000000..7c1e4c3f44eb --- /dev/null +++ b/.changeset/angry-dryers-hang.md @@ -0,0 +1,5 @@ +--- +"@astrojs/rss": patch +--- + +Restores `rssSchema` to a zod object diff --git a/.changeset/breezy-plants-smoke.md b/.changeset/breezy-plants-smoke.md new file mode 100644 index 000000000000..034ab448a56f --- /dev/null +++ b/.changeset/breezy-plants-smoke.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Fixes types generation from Content Collections config file diff --git a/.changeset/eight-turtles-itch.md b/.changeset/eight-turtles-itch.md deleted file mode 100644 index dbac472b9ad4..000000000000 --- a/.changeset/eight-turtles-itch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"astro": patch ---- - -Improves HMR behavior for style-only changes in `.astro` files diff --git a/.changeset/heavy-beers-tickle.md b/.changeset/heavy-beers-tickle.md new file mode 100644 index 000000000000..f6b19aa74b76 --- /dev/null +++ b/.changeset/heavy-beers-tickle.md @@ -0,0 +1,31 @@ +--- +"@astrojs/alpinejs": minor +--- + +Allows extending Alpine using the new `entrypoint` configuration + +You can extend Alpine by setting the `entrypoint` option to a root-relative import specifier (for example, `entrypoint: "/src/entrypoint"`). + +The default export of this file should be a function that accepts an Alpine instance prior to starting, allowing the use of custom directives, plugins and other customizations for advanced use cases. + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import alpine from '@astrojs/alpinejs'; + +export default defineConfig({ + // ... + integrations: [alpine({ entrypoint: '/src/entrypoint' })], +}); +``` + +```js +// src/entrypoint.ts +import type { Alpine } from 'alpinejs' + +export default (Alpine: Alpine) => { + Alpine.directive('foo', el => { + el.textContent = 'bar'; + }) +} +``` \ No newline at end of file diff --git a/.changeset/lemon-carrots-cheer.md b/.changeset/lemon-carrots-cheer.md deleted file mode 100644 index f7ef5c228737..000000000000 --- a/.changeset/lemon-carrots-cheer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@astrojs/markdown-remark": patch ---- - -Initializes internal `cwdUrlStr` variable lazily for performance, and workaround Rollup side-effect detection bug when building for non-Node runtimes diff --git a/.changeset/red-carrots-fail.md b/.changeset/red-carrots-fail.md deleted file mode 100644 index caef47017deb..000000000000 --- a/.changeset/red-carrots-fail.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"astro": patch ---- - -Adds support for dynamic slot names diff --git a/.changeset/selfish-donuts-approve.md b/.changeset/selfish-donuts-approve.md deleted file mode 100644 index f5cf4d6f9ebe..000000000000 --- a/.changeset/selfish-donuts-approve.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'astro': patch ---- - -Improves the CLI output of `astro preferences list` to include additional relevant information diff --git a/.changeset/strange-students-shake.md b/.changeset/strange-students-shake.md new file mode 100644 index 000000000000..9e5bd382bf3e --- /dev/null +++ b/.changeset/strange-students-shake.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Fixes environment variables replacement for `export const prerender` diff --git a/.changeset/sweet-owls-trade.md b/.changeset/sweet-owls-trade.md deleted file mode 100644 index 625b315e2c07..000000000000 --- a/.changeset/sweet-owls-trade.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"astro": patch ---- - -Allow i18n routing utilities like getRelativeLocaleUrl to also get the default local path when redirectToDefaultLocale is false diff --git a/.changeset/tame-crabs-reply.md b/.changeset/tame-crabs-reply.md new file mode 100644 index 000000000000..e20c17528e94 --- /dev/null +++ b/.changeset/tame-crabs-reply.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Refactors internals of the `astro:i18n` module to be more maintainable. diff --git a/.changeset/thick-carrots-run.md b/.changeset/thick-carrots-run.md deleted file mode 100644 index 2da50fae793a..000000000000 --- a/.changeset/thick-carrots-run.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@astrojs/markdown-remark": minor ---- - -Fixes usage in browser environments by using subpath imports diff --git a/scripts/notify/index.js b/.github/scripts/announce.mjs similarity index 80% rename from scripts/notify/index.js rename to .github/scripts/announce.mjs index 7dd8a3751767..6ac4c2d3bacd 100755 --- a/scripts/notify/index.js +++ b/.github/scripts/announce.mjs @@ -1,6 +1,7 @@ import { globby as glob } from 'globby'; import { fileURLToPath } from 'node:url'; import { readFile } from 'node:fs/promises'; +import { setOutput } from './utils.mjs'; const { GITHUB_REF = 'main' } = process.env; const baseUrl = new URL(`https://github.com/withastro/astro/blob/${GITHUB_REF}/`); @@ -17,34 +18,34 @@ const descriptors = [ 'updates', ]; const verbs = [ - 'just went out!', - 'just launched!', - 'now available!', - 'in the wild!', - 'now live!', - 'hit the registry!', - 'to share!', - 'for you!', - 'for y’all! 🀠', - 'comin’ your way!', - 'comin’ atcha!', - 'comin’ in hot!', - 'freshly minted on the blockchain! (jk)', - '[is] out (now with 100% more reticulated splines!)', - '(as seen on TV!)', - 'just dropped!', - '– artisanally hand-crafted just for you.', - '– oh happy day!', - '– enjoy!', - 'now out. Be the first on your block to download!', - 'made with love πŸ’•', - '[is] out! Our best [version] yet!', - '[is] here. DOWNLOAD! DOWNLOAD! DOWNLOAD!', - '... HUZZAH!', - '[has] landed!', - 'landed! The internet just got a little more fun.', - '– from our family to yours.', - '– go forth and build!', + "just went out!", + "just launched!", + "now available!", + "in the wild!", + "now live!", + "hit the registry!", + "to share!", + "for you!", + "for y’all! 🀠", + "comin’ your way!", + "comin’ atcha!", + "comin’ in hot!", + "freshly minted on the blockchain! (jk)", + "[is] out (now with 100% more reticulated splines!)", + "(as seen on TV!)", + "just dropped!", + "– artisanally hand-crafted just for you.", + "– oh happy day!", + "– enjoy!", + "now out. Be the first on your block to download!", + "made with love πŸ’•", + "[is] out! Our best [version] yet!", + "[is] here. DOWNLOAD! DOWNLOAD! DOWNLOAD!", + "... HUZZAH!", + "[has] landed!", + "landed! The internet just got a little more fun.", + "– from our family to yours.", + "– go forth and build!" ]; const extraVerbs = [ 'new', @@ -162,14 +163,7 @@ async function run() { process.exit(1); } const content = await generateMessage(); - - await fetch(`${process.env.DISCORD_WEBHOOK}?wait=true`, { - method: 'POST', - body: JSON.stringify({ content }), - headers: { - 'content-type': 'application/json', - }, - }); + setOutput('DISCORD_MESSAGE', content); } run(); diff --git a/.github/scripts/utils.mjs b/.github/scripts/utils.mjs new file mode 100644 index 000000000000..da5befc2c288 --- /dev/null +++ b/.github/scripts/utils.mjs @@ -0,0 +1,59 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as crypto from 'node:crypto' + +/** Based on https://github.com/actions/toolkit/blob/4e3b068ce116d28cb840033c02f912100b4592b0/packages/core/src/file-command.ts */ +export function setOutput(key, value) { + const filePath = process.env['GITHUB_OUTPUT'] || '' + if (filePath) { + return issueFileCommand('OUTPUT', prepareKeyValueMessage(key, value)) + } + process.stdout.write(os.EOL) +} + +function issueFileCommand(command, message) { + const filePath = process.env[`GITHUB_${command}`] + if (!filePath) { + throw new Error( + `Unable to find environment variable for file command ${command}` + ) + } + if (!fs.existsSync(filePath)) { + throw new Error(`Missing file at path: ${filePath}`) + } + + fs.appendFileSync(filePath, `${toCommandValue(message)}${os.EOL}`, { + encoding: 'utf8' + }) +} + +function prepareKeyValueMessage(key, value) { + const delimiter = `gh-delimiter-${crypto.randomUUID()}` + const convertedValue = toCommandValue(value) + + // These should realistically never happen, but just in case someone finds a + // way to exploit uuid generation let's not allow keys or values that contain + // the delimiter. + if (key.includes(delimiter)) { + throw new Error( + `Unexpected input: name should not contain the delimiter "${delimiter}"` + ) + } + + if (convertedValue.includes(delimiter)) { + throw new Error( + `Unexpected input: value should not contain the delimiter "${delimiter}"` + ) + } + + return `${key}<<${delimiter}${os.EOL}${convertedValue}${os.EOL}${delimiter}` +} + +function toCommandValue(input) { + if (input === null || input === undefined) { + return '' + } else if (typeof input === 'string' || input instanceof String) { + return input + } + return JSON.stringify(input) +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d25ec07e5617..68a1c971764d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,9 +59,17 @@ jobs: # Needs access to publish to npm NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Generate Notification - id: notification + - name: Generate Announcement + id: message if: steps.changesets.outputs.published == 'true' env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - run: node scripts/notify/index.js '${{ steps.changesets.outputs.publishedPackages }}' + run: node .github/scripts/announce.mjs '${{ steps.changesets.outputs.publishedPackages }}' + + - name: Send message on Discord + if: steps.changesets.outputs.published == 'true' + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + uses: Ilshidur/action-discord@0.3.2 + with: + args: "${{ steps.message.outputs.DISCORD_MESSAGE }}" diff --git a/examples/basics/package.json b/examples/basics/package.json index 5bd74167d042..6d4f97825b1d 100644 --- a/examples/basics/package.json +++ b/examples/basics/package.json @@ -11,6 +11,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^4.2.1" + "astro": "^4.2.4" } } diff --git a/examples/blog/package.json b/examples/blog/package.json index 6825d1c8d0e3..8a988f43cdf5 100644 --- a/examples/blog/package.json +++ b/examples/blog/package.json @@ -11,9 +11,9 @@ "astro": "astro" }, "dependencies": { - "@astrojs/mdx": "^2.0.5", - "@astrojs/rss": "^4.0.2", + "@astrojs/mdx": "^2.1.0", + "@astrojs/rss": "^4.0.3", "@astrojs/sitemap": "^3.0.5", - "astro": "^4.2.1" + "astro": "^4.2.4" } } diff --git a/examples/component/package.json b/examples/component/package.json index 6b549e712237..e34db92c04fd 100644 --- a/examples/component/package.json +++ b/examples/component/package.json @@ -15,9 +15,9 @@ ], "scripts": {}, "devDependencies": { - "astro": "^4.2.1" + "astro": "^4.2.4" }, "peerDependencies": { - "astro": "^3.0.0" + "astro": "^4.0.0" } } diff --git a/examples/framework-alpine/package.json b/examples/framework-alpine/package.json index 32b3a3c98631..d6dba38a7987 100644 --- a/examples/framework-alpine/package.json +++ b/examples/framework-alpine/package.json @@ -14,6 +14,6 @@ "@astrojs/alpinejs": "^0.3.2", "@types/alpinejs": "^3.13.5", "alpinejs": "^3.13.3", - "astro": "^4.2.1" + "astro": "^4.2.4" } } diff --git a/examples/framework-lit/package.json b/examples/framework-lit/package.json index 621523a24951..57a0cb109a66 100644 --- a/examples/framework-lit/package.json +++ b/examples/framework-lit/package.json @@ -13,7 +13,7 @@ "dependencies": { "@astrojs/lit": "^4.0.1", "@webcomponents/template-shadowroot": "^0.2.1", - "astro": "^4.2.1", + "astro": "^4.2.4", "lit": "^2.8.0" } } diff --git a/examples/framework-multiple/package.json b/examples/framework-multiple/package.json index 6d9a7d4f9bd5..aed44ca9455c 100644 --- a/examples/framework-multiple/package.json +++ b/examples/framework-multiple/package.json @@ -16,7 +16,7 @@ "@astrojs/solid-js": "^4.0.1", "@astrojs/svelte": "^5.0.3", "@astrojs/vue": "^4.0.8", - "astro": "^4.2.1", + "astro": "^4.2.4", "preact": "^10.19.2", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/examples/framework-preact/package.json b/examples/framework-preact/package.json index 1da422d8f447..4ddaa990a776 100644 --- a/examples/framework-preact/package.json +++ b/examples/framework-preact/package.json @@ -13,7 +13,7 @@ "dependencies": { "@astrojs/preact": "^3.1.0", "@preact/signals": "^1.2.1", - "astro": "^4.2.1", + "astro": "^4.2.4", "preact": "^10.19.2" } } diff --git a/examples/framework-react/package.json b/examples/framework-react/package.json index 432d585d80c7..f03359a0d716 100644 --- a/examples/framework-react/package.json +++ b/examples/framework-react/package.json @@ -14,7 +14,7 @@ "@astrojs/react": "^3.0.9", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", - "astro": "^4.2.1", + "astro": "^4.2.4", "react": "^18.2.0", "react-dom": "^18.2.0" } diff --git a/examples/framework-solid/package.json b/examples/framework-solid/package.json index 2f0ba2a787de..df5af1cd1498 100644 --- a/examples/framework-solid/package.json +++ b/examples/framework-solid/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@astrojs/solid-js": "^4.0.1", - "astro": "^4.2.1", + "astro": "^4.2.4", "solid-js": "^1.8.5" } } diff --git a/examples/framework-svelte/package.json b/examples/framework-svelte/package.json index 4e5fbf094de6..f2c35fd7c494 100644 --- a/examples/framework-svelte/package.json +++ b/examples/framework-svelte/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@astrojs/svelte": "^5.0.3", - "astro": "^4.2.1", + "astro": "^4.2.4", "svelte": "^4.2.5" } } diff --git a/examples/framework-vue/package.json b/examples/framework-vue/package.json index 60e0ae6ad622..5ff5b2cefb5e 100644 --- a/examples/framework-vue/package.json +++ b/examples/framework-vue/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@astrojs/vue": "^4.0.8", - "astro": "^4.2.1", + "astro": "^4.2.4", "vue": "^3.3.8" } } diff --git a/examples/hackernews/package.json b/examples/hackernews/package.json index be1bcd740ee6..b07972e13286 100644 --- a/examples/hackernews/package.json +++ b/examples/hackernews/package.json @@ -12,6 +12,6 @@ }, "dependencies": { "@astrojs/node": "^8.0.0", - "astro": "^4.2.1" + "astro": "^4.2.4" } } diff --git a/examples/integration/package.json b/examples/integration/package.json index 90803ebd9cd2..192a5d86a0e1 100644 --- a/examples/integration/package.json +++ b/examples/integration/package.json @@ -15,9 +15,9 @@ ], "scripts": {}, "devDependencies": { - "astro": "^4.2.1" + "astro": "^4.2.4" }, "peerDependencies": { - "astro": "^3.0.0" + "astro": "^4.0.0" } } diff --git a/examples/middleware/package.json b/examples/middleware/package.json index 06bcaa2682fc..dfc479a1aeb2 100644 --- a/examples/middleware/package.json +++ b/examples/middleware/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@astrojs/node": "^8.0.0", - "astro": "^4.2.1", + "astro": "^4.2.4", "html-minifier": "^4.0.0" }, "devDependencies": { diff --git a/examples/minimal/package.json b/examples/minimal/package.json index b481dfbe74b2..bf67cd4aebb7 100644 --- a/examples/minimal/package.json +++ b/examples/minimal/package.json @@ -11,6 +11,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^4.2.1" + "astro": "^4.2.4" } } diff --git a/examples/non-html-pages/package.json b/examples/non-html-pages/package.json index 5138dab7b1a8..57c4a3a0dc41 100644 --- a/examples/non-html-pages/package.json +++ b/examples/non-html-pages/package.json @@ -11,6 +11,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^4.2.1" + "astro": "^4.2.4" } } diff --git a/examples/portfolio/package.json b/examples/portfolio/package.json index edc358f6b034..24a8771d305e 100644 --- a/examples/portfolio/package.json +++ b/examples/portfolio/package.json @@ -11,6 +11,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^4.2.1" + "astro": "^4.2.4" } } diff --git a/examples/portfolio/src/styles/global.css b/examples/portfolio/src/styles/global.css index 77ed2d480741..1ff987e2f2ed 100644 --- a/examples/portfolio/src/styles/global.css +++ b/examples/portfolio/src/styles/global.css @@ -117,7 +117,7 @@ html, body { - height: 100%; + min-height: 100%; overflow-x: hidden; } diff --git a/examples/ssr/package.json b/examples/ssr/package.json index 5d88434baa16..2b37fc9ea9d9 100644 --- a/examples/ssr/package.json +++ b/examples/ssr/package.json @@ -14,7 +14,7 @@ "dependencies": { "@astrojs/node": "^8.0.0", "@astrojs/svelte": "^5.0.3", - "astro": "^4.2.1", + "astro": "^4.2.4", "svelte": "^4.2.5" } } diff --git a/examples/starlog/package.json b/examples/starlog/package.json index 41b698aa7359..cc877fb2bc79 100644 --- a/examples/starlog/package.json +++ b/examples/starlog/package.json @@ -10,7 +10,7 @@ "astro": "astro" }, "dependencies": { - "astro": "^4.2.1", + "astro": "^4.2.4", "sass": "^1.69.5", "sharp": "^0.32.6" } diff --git a/examples/view-transitions/package.json b/examples/view-transitions/package.json index 3c324d50cbad..39b0101b0f8b 100644 --- a/examples/view-transitions/package.json +++ b/examples/view-transitions/package.json @@ -12,6 +12,6 @@ "devDependencies": { "@astrojs/tailwind": "^5.1.0", "@astrojs/node": "^8.0.0", - "astro": "^4.2.1" + "astro": "^4.2.4" } } diff --git a/examples/with-markdoc/package.json b/examples/with-markdoc/package.json index fe7d09f2f9f2..83bd3c048945 100644 --- a/examples/with-markdoc/package.json +++ b/examples/with-markdoc/package.json @@ -12,6 +12,6 @@ }, "dependencies": { "@astrojs/markdoc": "^0.8.3", - "astro": "^4.2.1" + "astro": "^4.2.4" } } diff --git a/examples/with-markdown-plugins/package.json b/examples/with-markdown-plugins/package.json index 221ad543b12c..47b517cf5724 100644 --- a/examples/with-markdown-plugins/package.json +++ b/examples/with-markdown-plugins/package.json @@ -11,8 +11,8 @@ "astro": "astro" }, "dependencies": { - "@astrojs/markdown-remark": "^4.1.0", - "astro": "^4.2.1", + "@astrojs/markdown-remark": "^4.2.0", + "astro": "^4.2.4", "hast-util-select": "^6.0.2", "rehype-autolink-headings": "^7.1.0", "rehype-slug": "^6.0.0", diff --git a/examples/with-markdown-shiki/package.json b/examples/with-markdown-shiki/package.json index e37aeb1d17cb..c2eea06f37f7 100644 --- a/examples/with-markdown-shiki/package.json +++ b/examples/with-markdown-shiki/package.json @@ -11,6 +11,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^4.2.1" + "astro": "^4.2.4" } } diff --git a/examples/with-mdx/package.json b/examples/with-mdx/package.json index b4746a24f7f2..85ed71cc15fe 100644 --- a/examples/with-mdx/package.json +++ b/examples/with-mdx/package.json @@ -11,9 +11,9 @@ "astro": "astro" }, "dependencies": { - "@astrojs/mdx": "^2.0.5", + "@astrojs/mdx": "^2.1.0", "@astrojs/preact": "^3.1.0", - "astro": "^4.2.1", + "astro": "^4.2.4", "preact": "^10.19.2" } } diff --git a/examples/with-nanostores/package.json b/examples/with-nanostores/package.json index 184edb41151b..801fd3acd397 100644 --- a/examples/with-nanostores/package.json +++ b/examples/with-nanostores/package.json @@ -13,7 +13,7 @@ "dependencies": { "@astrojs/preact": "^3.1.0", "@nanostores/preact": "^0.5.0", - "astro": "^4.2.1", + "astro": "^4.2.4", "nanostores": "^0.9.5", "preact": "^10.19.2" } diff --git a/examples/with-tailwindcss/package.json b/examples/with-tailwindcss/package.json index bfae6b612005..30658b5cc861 100644 --- a/examples/with-tailwindcss/package.json +++ b/examples/with-tailwindcss/package.json @@ -11,10 +11,10 @@ "astro": "astro" }, "dependencies": { - "@astrojs/mdx": "^2.0.5", + "@astrojs/mdx": "^2.1.0", "@astrojs/tailwind": "^5.1.0", "@types/canvas-confetti": "^1.6.3", - "astro": "^4.2.1", + "astro": "^4.2.4", "autoprefixer": "^10.4.15", "canvas-confetti": "^1.9.1", "postcss": "^8.4.28", diff --git a/examples/with-vitest/package.json b/examples/with-vitest/package.json index 96450f6afd5e..7f601435e31d 100644 --- a/examples/with-vitest/package.json +++ b/examples/with-vitest/package.json @@ -12,7 +12,7 @@ "test": "vitest" }, "dependencies": { - "astro": "^4.2.1", - "vitest": "^0.34.2" + "astro": "^4.2.4", + "vitest": "^1.2.1" } } diff --git a/package.json b/package.json index ad073d17d513..f85212d923e8 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "prettier": "^3.1.0", "prettier-plugin-astro": "^0.12.2", "tiny-glob": "^0.2.9", + "globby": "^14.0.0", "turbo": "^1.10.12", "typescript": "~5.2.2" }, diff --git a/packages/astro-rss/CHANGELOG.md b/packages/astro-rss/CHANGELOG.md index 9ac0638b3b88..e12cfe35870d 100644 --- a/packages/astro-rss/CHANGELOG.md +++ b/packages/astro-rss/CHANGELOG.md @@ -1,5 +1,11 @@ # @astrojs/rss +## 4.0.3 + +### Patch Changes + +- [#9746](https://github.com/withastro/astro/pull/9746) [`7356336d18c916804001bdf64bff5445d82baceb`](https://github.com/withastro/astro/commit/7356336d18c916804001bdf64bff5445d82baceb) Thanks [@florian-lefebvre](https://github.com/florian-lefebvre)! - Fixes `rssSchema` definition to allow calling standard zod object methods (like `extend`) + ## 4.0.2 ### Patch Changes diff --git a/packages/astro-rss/package.json b/packages/astro-rss/package.json index 1459d4958e47..e3ae85a46697 100644 --- a/packages/astro-rss/package.json +++ b/packages/astro-rss/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/rss", "description": "Add RSS feeds to your Astro projects", - "version": "4.0.2", + "version": "4.0.3", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", diff --git a/packages/astro-rss/src/index.ts b/packages/astro-rss/src/index.ts index b866365ab342..b3e0253d2ce7 100644 --- a/packages/astro-rss/src/index.ts +++ b/packages/astro-rss/src/index.ts @@ -137,7 +137,12 @@ export function pagesGlobToRssItems(items: GlobResult): Promise val.title || val.description, { + message: 'At least title or description must be provided.', + path: ['title', 'description'], + }) + .safeParse({ ...frontmatter, link: url }, { errorMap }); if (parsedResult.success) { return parsedResult.data; diff --git a/packages/astro-rss/src/schema.ts b/packages/astro-rss/src/schema.ts index 773d39cf2020..79d2e5987636 100644 --- a/packages/astro-rss/src/schema.ts +++ b/packages/astro-rss/src/schema.ts @@ -22,7 +22,4 @@ export const rssSchema = z.object({ .optional(), link: z.string().optional(), content: z.string().optional(), -}).refine(val => val.title || val.description, { - message: "At least title or description must be provided.", - path: ["title", "description"] -}) \ No newline at end of file +}); diff --git a/packages/astro-rss/test/rss.test.js b/packages/astro-rss/test/rss.test.js index 3f37e88a5699..833abf91c9b7 100644 --- a/packages/astro-rss/test/rss.test.js +++ b/packages/astro-rss/test/rss.test.js @@ -1,6 +1,7 @@ import chai from 'chai'; import chaiPromises from 'chai-as-promised'; import chaiXml from 'chai-xml'; +import { z } from 'astro/zod'; import rss, { getRssString } from '../dist/index.js'; import { rssSchema } from '../dist/schema.js'; import { @@ -214,4 +215,16 @@ describe('getRssString', () => { chai.expect(res.success).to.be.false; chai.expect(res.error.issues[0].path[0]).to.equal('pubDate'); }); + + it('should be extendable', () => { + let error = null; + try { + rssSchema.extend({ + category: z.string().optional(), + }); + } catch (e) { + error = e.message; + } + chai.expect(error).to.be.null; + }); }); diff --git a/packages/astro/CHANGELOG.md b/packages/astro/CHANGELOG.md index 39573513f102..84a4818b920a 100644 --- a/packages/astro/CHANGELOG.md +++ b/packages/astro/CHANGELOG.md @@ -1,5 +1,84 @@ # astro +## 4.2.4 + +### Patch Changes + +- [#9792](https://github.com/withastro/astro/pull/9792) [`e22cb8b10c0ca9f6d88cab53cd2713f57875ab4b`](https://github.com/withastro/astro/commit/e22cb8b10c0ca9f6d88cab53cd2713f57875ab4b) Thanks [@tugrulates](https://github.com/tugrulates)! - Accept aria role `switch` on toolbar audit. + +- [#9606](https://github.com/withastro/astro/pull/9606) [`e6945bcf23b6ad29388bbadaf5bb3cc31dd4a114`](https://github.com/withastro/astro/commit/e6945bcf23b6ad29388bbadaf5bb3cc31dd4a114) Thanks [@eryue0220](https://github.com/eryue0220)! - Fixes escaping behavior for `.html` files and components + +- [#9786](https://github.com/withastro/astro/pull/9786) [`5b29550996a7f5459a0d611feea6e51d44e1d8ed`](https://github.com/withastro/astro/commit/5b29550996a7f5459a0d611feea6e51d44e1d8ed) Thanks [@Fryuni](https://github.com/Fryuni)! - Fixes a regression in routing priority for index pages in rest parameter folders and dynamic sibling trees. + + Considering the following tree: + + ``` + src/pages/ + β”œβ”€β”€ index.astro + β”œβ”€β”€ static.astro + β”œβ”€β”€ [dynamic_file].astro + β”œβ”€β”€ [...rest_file].astro + β”œβ”€β”€ blog/ + β”‚ └── index.astro + β”œβ”€β”€ [dynamic_folder]/ + β”‚ β”œβ”€β”€ index.astro + β”‚ β”œβ”€β”€ static.astro + β”‚ └── [...rest].astro + └── [...rest_folder]/ + β”œβ”€β”€ index.astro + └── static.astro + ``` + + The routes are sorted in this order: + + ``` + /src/pages/index.astro + /src/pages/blog/index.astro + /src/pages/static.astro + /src/pages/[dynamic_folder]/index.astro + /src/pages/[dynamic_file].astro + /src/pages/[dynamic_folder]/static.astro + /src/pages/[dynamic_folder]/[...rest].astro + /src/pages/[...rest_folder]/static.astro + /src/pages/[...rest_folder]/index.astro + /src/pages/[...rest_file]/index.astro + ``` + + This allows for index files to be used as overrides to rest parameter routes on SSR when the rest parameter matching `undefined` is not desired. + +- [#9775](https://github.com/withastro/astro/pull/9775) [`075706f26d2e11e66ef8b52288d07e3c0fa97eb1`](https://github.com/withastro/astro/commit/075706f26d2e11e66ef8b52288d07e3c0fa97eb1) Thanks [@lilnasy](https://github.com/lilnasy)! - Simplifies internals that handle endpoints. + +- [#9773](https://github.com/withastro/astro/pull/9773) [`9aa7a5368c502ae488d3a173e732d81f3d000e98`](https://github.com/withastro/astro/commit/9aa7a5368c502ae488d3a173e732d81f3d000e98) Thanks [@LunaticMuch](https://github.com/LunaticMuch)! - Raises the required vite version to address a vulnerability in `vite.server.fs.deny` that affected the dev mode. + +- [#9781](https://github.com/withastro/astro/pull/9781) [`ccc05d54014e24c492ca5fddd4862f318aac8172`](https://github.com/withastro/astro/commit/ccc05d54014e24c492ca5fddd4862f318aac8172) Thanks [@stevenbenner](https://github.com/stevenbenner)! - Fix build failure when image file name includes special characters + +## 4.2.3 + +### Patch Changes + +- [#9768](https://github.com/withastro/astro/pull/9768) [`eed0e8757c35dde549707e71c45862438a043fb0`](https://github.com/withastro/astro/commit/eed0e8757c35dde549707e71c45862438a043fb0) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Fix apps being able to crash the dev toolbar in certain cases + +## 4.2.2 + +### Patch Changes + +- [#9712](https://github.com/withastro/astro/pull/9712) [`ea6cbd06a2580527786707ec735079ff9abd0ec0`](https://github.com/withastro/astro/commit/ea6cbd06a2580527786707ec735079ff9abd0ec0) Thanks [@bluwy](https://github.com/bluwy)! - Improves HMR behavior for style-only changes in `.astro` files + +- [#9739](https://github.com/withastro/astro/pull/9739) [`3ecb3ef64326a8f77aa170df1e3c89cb5c12cc93`](https://github.com/withastro/astro/commit/3ecb3ef64326a8f77aa170df1e3c89cb5c12cc93) Thanks [@ematipico](https://github.com/ematipico)! - Makes i18n redirects take the `build.format` configuration into account + +- [#9762](https://github.com/withastro/astro/pull/9762) [`1fba85681e86aa83d24336d4209cafbc76b37607`](https://github.com/withastro/astro/commit/1fba85681e86aa83d24336d4209cafbc76b37607) Thanks [@ematipico](https://github.com/ematipico)! - Adds `popovertarget" to the attribute that can be passed to the `button` element + +- [#9605](https://github.com/withastro/astro/pull/9605) [`8ce40a417c854d9e6a4fa7d5a85d50a6436b4a3c`](https://github.com/withastro/astro/commit/8ce40a417c854d9e6a4fa7d5a85d50a6436b4a3c) Thanks [@MoustaphaDev](https://github.com/MoustaphaDev)! - Adds support for dynamic slot names + +- [#9381](https://github.com/withastro/astro/pull/9381) [`9e01f9cc1efcfb938355829676d51b24818ab2bb`](https://github.com/withastro/astro/commit/9e01f9cc1efcfb938355829676d51b24818ab2bb) Thanks [@martrapp](https://github.com/martrapp)! - Improves the CLI output of `astro preferences list` to include additional relevant information + +- [#9741](https://github.com/withastro/astro/pull/9741) [`73d74402007896204ee965f6553dc83b3dec8d2f`](https://github.com/withastro/astro/commit/73d74402007896204ee965f6553dc83b3dec8d2f) Thanks [@taktran](https://github.com/taktran)! - Fixes an issue where dot files were not copied over from the public folder to the output folder, when build command was run in a folder other than the root of the project. + +- [#9730](https://github.com/withastro/astro/pull/9730) [`8d2e5db096f1e7b098511b4fe9357434a6ff0703`](https://github.com/withastro/astro/commit/8d2e5db096f1e7b098511b4fe9357434a6ff0703) Thanks [@Blede2000](https://github.com/Blede2000)! - Allow i18n routing utilities like getRelativeLocaleUrl to also get the default local path when redirectToDefaultLocale is false + +- Updated dependencies [[`53c69dcc82cdf4000aff13a6c11fffe19096cf45`](https://github.com/withastro/astro/commit/53c69dcc82cdf4000aff13a6c11fffe19096cf45), [`2f81cffa9da9db0e2802d303f94feaee8d2f54ec`](https://github.com/withastro/astro/commit/2f81cffa9da9db0e2802d303f94feaee8d2f54ec), [`a505190933365268d48139a5f197a3cfb5570870`](https://github.com/withastro/astro/commit/a505190933365268d48139a5f197a3cfb5570870)]: + - @astrojs/markdown-remark@4.2.0 + ## 4.2.1 ### Patch Changes diff --git a/packages/astro/astro-jsx.d.ts b/packages/astro/astro-jsx.d.ts index f79c5a36544a..accd9002532c 100644 --- a/packages/astro/astro-jsx.d.ts +++ b/packages/astro/astro-jsx.d.ts @@ -647,6 +647,7 @@ declare namespace astroHTML.JSX { name?: string | undefined | null; type?: 'submit' | 'reset' | 'button' | undefined | null; value?: string | string[] | number | undefined | null; + popovertarget?: string | undefined | null; } interface CanvasHTMLAttributes extends HTMLAttributes { @@ -811,6 +812,7 @@ declare namespace astroHTML.JSX { type?: HTMLInputTypeAttribute | undefined | null; value?: string | string[] | number | undefined | null; width?: number | string | undefined | null; + popovertarget?: string | undefined | null; } interface KeygenHTMLAttributes extends HTMLAttributes { diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index 3b30d7700886..8542faa471a9 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -149,147 +149,7 @@ declare module 'astro:prefetch' { } declare module 'astro:i18n' { - export type GetLocaleOptions = import('./dist/virtual-modules/i18n.js').GetLocaleOptions; - - /** - * @param {string} locale A locale - * @param {string} [path=""] An optional path to add after the `locale`. - * @param {import('./dist/virtual-modules/i18n.js').GetLocaleOptions} options Customise the generated path - * @return {string} - * - * Returns a _relative_ path with passed locale. - * - * ## Errors - * - * Throws an error if the locale doesn't exist in the list of locales defined in the configuration. - * - * ## Examples - * - * ```js - * import { getRelativeLocaleUrl } from "astro:i18n"; - * getRelativeLocaleUrl("es"); // /es - * getRelativeLocaleUrl("es", "getting-started"); // /es/getting-started - * getRelativeLocaleUrl("es_US", "getting-started", { prependWith: "blog" }); // /blog/es-us/getting-started - * getRelativeLocaleUrl("es_US", "getting-started", { prependWith: "blog", normalizeLocale: false }); // /blog/es_US/getting-started - * ``` - */ - export const getRelativeLocaleUrl: ( - locale: string, - path?: string, - options?: GetLocaleOptions - ) => string; - - /** - * - * @param {string} locale A locale - * @param {string} [path=""] An optional path to add after the `locale`. - * @param {import('./dist/virtual-modules/i18n.js').GetLocaleOptions} options Customise the generated path - * @return {string} - * - * Returns an absolute path with the passed locale. The behaviour is subject to change based on `site` configuration. - * If _not_ provided, the function will return a _relative_ URL. - * - * ## Errors - * - * Throws an error if the locale doesn't exist in the list of locales defined in the configuration. - * - * ## Examples - * - * If `site` is `https://example.com`: - * - * ```js - * import { getAbsoluteLocaleUrl } from "astro:i18n"; - * getAbsoluteLocaleUrl("es"); // https://example.com/es - * getAbsoluteLocaleUrl("es", "getting-started"); // https://example.com/es/getting-started - * getAbsoluteLocaleUrl("es_US", "getting-started", { prependWith: "blog" }); // https://example.com/blog/es-us/getting-started - * getAbsoluteLocaleUrl("es_US", "getting-started", { prependWith: "blog", normalizeLocale: false }); // https://example.com/blog/es_US/getting-started - * ``` - */ - export const getAbsoluteLocaleUrl: ( - locale: string, - path?: string, - options?: GetLocaleOptions - ) => string; - - /** - * @param {string} [path=""] An optional path to add after the `locale`. - * @param {import('./dist/virtual-modules/i18n.js').GetLocaleOptions} options Customise the generated path - * @return {string[]} - * - * Works like `getRelativeLocaleUrl` but it emits the relative URLs for ALL locales: - */ - export const getRelativeLocaleUrlList: (path?: string, options?: GetLocaleOptions) => string[]; - /** - * @param {string} [path=""] An optional path to add after the `locale`. - * @param {import('./dist/virtual-modules/i18n.js').GetLocaleOptions} options Customise the generated path - * @return {string[]} - * - * Works like `getAbsoluteLocaleUrl` but it emits the absolute URLs for ALL locales: - */ - export const getAbsoluteLocaleUrlList: (path?: string, options?: GetLocaleOptions) => string[]; - - /** - * A function that return the `path` associated to a locale (defined as code). It's particularly useful in case you decide - * to use locales that are broken down in paths and codes. - * - * @param {string} code The code of the locale - * @returns {string} The path associated to the locale - * - * ## Example - * - * ```js - * // astro.config.mjs - * - * export default defineConfig({ - * i18n: { - * locales: [ - * { codes: ["it", "it-VT"], path: "italiano" }, - * "es" - * ] - * } - * }) - * ``` - * - * ```js - * import { getPathByLocale } from "astro:i18n"; - * getPathByLocale("it"); // returns "italiano" - * getPathByLocale("it-VT"); // returns "italiano" - * getPathByLocale("es"); // returns "es" - * ``` - */ - export const getPathByLocale: (code: string) => string; - - /** - * A function that returns the preferred locale given a certain path. This is particularly useful if you configure a locale using - * `path` and `codes`. When you define multiple `code`, this function will return the first code of the array. - * - * Astro will treat the first code as the one that the user prefers. - * - * @param {string} path The path that maps to a locale - * @returns {string} The path associated to the locale - * - * ## Example - * - * ```js - * // astro.config.mjs - * - * export default defineConfig({ - * i18n: { - * locales: [ - * { codes: ["it-VT", "it"], path: "italiano" }, - * "es" - * ] - * } - * }) - * ``` - * - * ```js - * import { getLocaleByPath } from "astro:i18n"; - * getLocaleByPath("italiano"); // returns "it-VT" because that's the first code configured - * getLocaleByPath("es"); // returns "es" - * ``` - */ - export const getLocaleByPath: (path: string) => string; + export * from 'astro/virtual-modules/i18n.js'; } declare module 'astro:middleware' { diff --git a/packages/astro/package.json b/packages/astro/package.json index aeadbc8893f8..ce8dae159bf7 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -1,6 +1,6 @@ { "name": "astro", - "version": "4.2.1", + "version": "4.2.4", "description": "Astro is a modern site builder with web best practices, performance, and DX front-of-mind.", "type": "module", "author": "withastro", @@ -172,7 +172,7 @@ "tsconfck": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.1", - "vite": "^5.0.10", + "vite": "^5.0.12", "vitefu": "^0.2.5", "which-pm": "^2.1.1", "yargs-parser": "^21.1.1", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 49d3ab991305..8a26ebaba6cd 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1535,6 +1535,9 @@ export interface AstroUserConfig { }; }; + /** ⚠️ WARNING: SUBJECT TO CHANGE */ + db?: Config.Database; + /** * @docs * @kind heading @@ -2699,7 +2702,7 @@ export interface ClientDirectiveConfig { export interface DevToolbarApp { id: string; name: string; - icon: Icon; + icon?: Icon; init?(canvas: ShadowRoot, eventTarget: EventTarget): void | Promise; beforeTogglingOff?(canvas: ShadowRoot): boolean | Promise; } @@ -2743,3 +2746,10 @@ declare global { 'astro-dev-overlay-card': DevToolbarCard; } } + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Config { + type Database = Record; + } +} diff --git a/packages/astro/src/cli/db/index.ts b/packages/astro/src/cli/db/index.ts new file mode 100644 index 000000000000..8b45e88e0318 --- /dev/null +++ b/packages/astro/src/cli/db/index.ts @@ -0,0 +1,30 @@ +import type { Arguments } from 'yargs-parser'; +import { createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js'; +import { getPackage } from '../install-package.js'; +import { resolveConfig } from '../../core/config/config.js'; +import type { AstroConfig } from '../../@types/astro.js'; + +type DBPackage = { + cli: (args: { flags: Arguments; config: AstroConfig }) => unknown; +}; + +export async function db({ flags }: { flags: Arguments }) { + const logger = createLoggerFromFlags(flags); + const getPackageOpts = { skipAsk: flags.yes || flags.y, cwd: flags.root }; + const dbPackage = await getPackage('@astrojs/db', logger, getPackageOpts, []); + + if (!dbPackage) { + logger.error( + 'check', + 'The `@astrojs/db` package is required for this command to work. Please manually install it in your project and try again.' + ); + return; + } + + const { cli } = dbPackage; + + const inlineConfig = flagsToAstroInlineConfig(flags); + const { astroConfig } = await resolveConfig(inlineConfig, 'build'); + + await cli({ flags, config: astroConfig }); +} diff --git a/packages/astro/src/cli/index.ts b/packages/astro/src/cli/index.ts index 19347ebecd16..a6ca88400d6a 100644 --- a/packages/astro/src/cli/index.ts +++ b/packages/astro/src/cli/index.ts @@ -11,6 +11,7 @@ type CLICommand = | 'dev' | 'build' | 'preview' + | 'db' | 'sync' | 'check' | 'info' @@ -72,6 +73,7 @@ function resolveCommand(flags: yargs.Arguments): CLICommand { 'preview', 'check', 'docs', + 'db', 'info', ]); if (supportedCommands.has(cmd)) { @@ -143,6 +145,11 @@ async function runCommand(cmd: string, flags: yargs.Arguments) { await add(packages, { flags }); return; } + case 'db': { + const { db } = await import('./db/index.js'); + await db({ flags }); + return; + } case 'dev': { const { dev } = await import('./dev/index.js'); const server = await dev({ flags }); diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts index 4d466f594223..d08f15109230 100644 --- a/packages/astro/src/content/types-generator.ts +++ b/packages/astro/src/content/types-generator.ts @@ -333,6 +333,22 @@ function invalidateVirtualMod(viteServer: ViteDevServer) { viteServer.moduleGraph.invalidateModule(virtualMod); } +/** + * Takes the source (`from`) and destination (`to`) of a config path and + * returns a normalized relative version: + * - If is not relative, it adds `./` to the beginning. + * - If it ends with `.ts`, it replaces it with `.js`. + * - It adds `""` around the string. + * @param from Config path source. + * @param to Config path destination. + * @returns Normalized config path. + */ +function normalizeConfigPath(from: string, to: string) { + const configPath = path.relative(from, to).replace(/\.ts$/, '.js'); + + return `"${isRelativePath(configPath) ? '' : './'}${configPath}"` as const; +} + async function writeContentFiles({ fs, contentPaths, @@ -415,18 +431,11 @@ async function writeContentFiles({ fs.mkdirSync(contentPaths.cacheDir, { recursive: true }); } - let configPathRelativeToCacheDir = normalizePath( - path.relative(contentPaths.cacheDir.pathname, contentPaths.config.url.pathname) + const configPathRelativeToCacheDir = normalizeConfigPath( + contentPaths.cacheDir.pathname, + contentPaths.config.url.pathname ); - if (!isRelativePath(configPathRelativeToCacheDir)) - configPathRelativeToCacheDir = './' + configPathRelativeToCacheDir; - - // Remove `.ts` from import path - if (configPathRelativeToCacheDir.endsWith('.ts')) { - configPathRelativeToCacheDir = configPathRelativeToCacheDir.replace(/\.ts$/, ''); - } - for (const contentEntryType of contentEntryTypes) { if (contentEntryType.contentModuleTypes) { typeTemplateContent = contentEntryType.contentModuleTypes + '\n' + typeTemplateContent; @@ -436,7 +445,7 @@ async function writeContentFiles({ typeTemplateContent = typeTemplateContent.replace('// @@DATA_ENTRY_MAP@@', dataTypesStr); typeTemplateContent = typeTemplateContent.replace( "'@@CONTENT_CONFIG_TYPE@@'", - contentConfig ? `typeof import(${JSON.stringify(configPathRelativeToCacheDir)})` : 'never' + contentConfig ? `typeof import(${configPathRelativeToCacheDir})` : 'never' ); await fs.promises.writeFile( diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index ad207d129054..6189b00f8efe 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -25,7 +25,7 @@ import { createStylesheetElementSet, } from '../render/ssr-element.js'; import { matchRoute } from '../routing/match.js'; -import { EndpointNotFoundError, SSRRoutePipeline } from './ssrPipeline.js'; +import { SSRRoutePipeline } from './ssrPipeline.js'; import type { RouteInfo } from './types.js'; export { deserializeManifest } from './common.js'; @@ -255,7 +255,8 @@ export class App { const i18nMiddleware = createI18nMiddleware( this.#manifest.i18n, this.#manifest.base, - this.#manifest.trailingSlash + this.#manifest.trailingSlash, + this.#manifest.buildFormat ); if (i18nMiddleware) { if (mod.onRequest) { @@ -271,28 +272,30 @@ export class App { } response = await this.#pipeline.renderRoute(renderContext, pageModule); } catch (err: any) { - if (err instanceof EndpointNotFoundError) { - return this.#renderError(request, { status: 404, response: err.originalResponse }); - } else { - this.#logger.error(null, err.stack || err.message || String(err)); - return this.#renderError(request, { status: 500 }); - } + this.#logger.error(null, err.stack || err.message || String(err)); + return this.#renderError(request, { status: 500 }); } - // endpoints do not participate in implicit rerouting - if (routeData.type === 'page' || routeData.type === 'redirect') { - if (REROUTABLE_STATUS_CODES.has(response.status)) { - return this.#renderError(request, { - response, - status: response.status as 404 | 500, - }); - } + if ( + REROUTABLE_STATUS_CODES.has(response.status) && + response.headers.get('X-Astro-Reroute') !== 'no' + ) { + return this.#renderError(request, { + response, + status: response.status as 404 | 500, + }); + } + + if (response.headers.has('X-Astro-Reroute')) { + response.headers.delete('X-Astro-Reroute'); } + if (addCookieHeader) { for (const setCookieHeaderValue of App.getSetCookieFromResponse(response)) { response.headers.append('set-cookie', setCookieHeaderValue); } } + Reflect.set(response, responseSentSymbol, true); return response; } diff --git a/packages/astro/src/core/app/ssrPipeline.ts b/packages/astro/src/core/app/ssrPipeline.ts index 94e8c9139cd2..f31636f9a51f 100644 --- a/packages/astro/src/core/app/ssrPipeline.ts +++ b/packages/astro/src/core/app/ssrPipeline.ts @@ -1,28 +1,3 @@ import { Pipeline } from '../pipeline.js'; -import type { Environment } from '../render/index.js'; -/** - * Thrown when an endpoint contains a response with the header "X-Astro-Response" === 'Not-Found' - */ -export class EndpointNotFoundError extends Error { - originalResponse: Response; - constructor(originalResponse: Response) { - super(); - this.originalResponse = originalResponse; - } -} - -export class SSRRoutePipeline extends Pipeline { - constructor(env: Environment) { - super(env); - this.setEndpointHandler(this.#ssrEndpointHandler); - } - - // This function is responsible for handling the result coming from an endpoint. - async #ssrEndpointHandler(request: Request, response: Response): Promise { - if (response.headers.get('X-Astro-Response') === 'Not-Found') { - throw new EndpointNotFoundError(response); - } - return response; - } -} +export class SSRRoutePipeline extends Pipeline {} diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index c0dbb54e75de..b38f51d64f72 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -40,6 +40,7 @@ export type SSRManifest = { site?: string; base: string; trailingSlash: 'always' | 'never' | 'ignore'; + buildFormat: 'file' | 'directory'; compressHTML: boolean; assetsPrefix?: string; renderers: SSRLoadedRenderer[]; diff --git a/packages/astro/src/core/build/buildPipeline.ts b/packages/astro/src/core/build/buildPipeline.ts index 166e42a2f8cb..c5a499d7023a 100644 --- a/packages/astro/src/core/build/buildPipeline.ts +++ b/packages/astro/src/core/build/buildPipeline.ts @@ -67,7 +67,6 @@ export class BuildPipeline extends Pipeline { this.#internals = internals; this.#staticBuildOptions = staticBuildOptions; this.#manifest = manifest; - this.setEndpointHandler(this.#handleEndpointResult); } getInternals(): Readonly { @@ -193,8 +192,4 @@ export class BuildPipeline extends Pipeline { return pages; } - - async #handleEndpointResult(_: Request, response: Response): Promise { - return response; - } } diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index a58eb0742329..e3b93ea44f62 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -265,7 +265,8 @@ async function generatePage( const i18nMiddleware = createI18nMiddleware( pipeline.getManifest().i18n, pipeline.getManifest().base, - pipeline.getManifest().trailingSlash + pipeline.getManifest().trailingSlash, + pipeline.getManifest().buildFormat ); if (config.i18n && i18nMiddleware) { if (onRequest) { @@ -486,7 +487,8 @@ async function generatePath( ) { const { mod, scripts: hoistedScripts, styles: _styles } = gopts; const manifest = pipeline.getManifest(); - pipeline.getEnvironment().logger.debug('build', `Generating: ${pathname}`); + const logger = pipeline.getLogger(); + logger.debug('build', `Generating: ${pathname}`); const links = new Set(); const scripts = createModuleScriptsSet( @@ -657,5 +659,6 @@ export function createBuildManifest( : settings.config.site, componentMetadata: internals.componentMetadata, i18n: i18nManifest, + buildFormat: settings.config.build.format, }; } diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index ebdb4734e304..09408e23af36 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -264,5 +264,6 @@ function buildManifest( entryModules, assets: staticFiles.map(prefixAssetPath), i18n: i18nManifest, + buildFormat: settings.config.build.format, }; } diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 86b7740c4b64..c4ecfd6a2448 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -439,7 +439,7 @@ async function cleanServerOutput( // Clean out directly if the outDir is outside of root if (out.toString() !== opts.settings.config.outDir.toString()) { // Copy assets before cleaning directory if outside root - await copyFiles(out, opts.settings.config.outDir); + await copyFiles(out, opts.settings.config.outDir, true); await fs.promises.rm(out, { recursive: true }); return; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 655db8ed8f5f..29c817bce052 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -112,6 +112,7 @@ export const AstroConfigSchema = z.object({ .optional() .default('attribute'), adapter: z.object({ name: z.string(), hooks: z.object({}).passthrough().default({}) }).optional(), + db: z.object({}).passthrough().default({}).optional(), integrations: z.preprocess( // preprocess (val) => (Array.isArray(val) ? val.flat(Infinity).filter(Boolean) : val), diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 99082b830419..0b0c7d47ff04 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -143,7 +143,7 @@ export async function createVite( astroTransitions({ settings }), astroDevToolbar({ settings, logger }), vitePluginFileURL({}), - !!settings.config.i18n && astroInternationalization({ settings }), + astroInternationalization({ settings }), ], publicDir: fileURLToPath(settings.config.publicDir), root: fileURLToPath(settings.config.root), diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index fb463a77fe1c..74bd29770280 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1034,6 +1034,18 @@ export const UnhandledRejection = { hint: 'Make sure your promises all have an `await` or a `.catch()` handler.', } satisfies ErrorData; +/** + * @docs + * @description + * Astro could not find any code to handle a rejected `Promise`. Make sure all your promises have an `await` or `.catch()` handler. + */ +export const i18nNotEnabled = { + name: 'i18nNotEnabled', + title: 'i18n Not Enabled', + message: 'The `astro:i18n` module can not be used without enabling i18n in your Astro config.', + hint: 'See https://docs.astro.build/en/guides/internationalization for a guide on setting up i18n.', +} satisfies ErrorData; + /** * @docs * @kind heading diff --git a/packages/astro/src/core/logger/core.ts b/packages/astro/src/core/logger/core.ts index f2230f754296..3f9c9f4177b5 100644 --- a/packages/astro/src/core/logger/core.ts +++ b/packages/astro/src/core/logger/core.ts @@ -27,6 +27,7 @@ export type LoggerLabel = | 'middleware' | 'preferences' | 'redirects' + | 'toolbar' // SKIP_FORMAT: A special label that tells the logger not to apply any formatting. // Useful for messages that are already formatted, like the server start message. | 'SKIP_FORMAT'; diff --git a/packages/astro/src/core/pipeline.ts b/packages/astro/src/core/pipeline.ts index 67081a47e93f..88b8e800daaa 100644 --- a/packages/astro/src/core/pipeline.ts +++ b/packages/astro/src/core/pipeline.ts @@ -4,11 +4,6 @@ import { callMiddleware } from './middleware/callMiddleware.js'; import { renderPage } from './render/core.js'; import { type Environment, type RenderContext } from './render/index.js'; -type EndpointResultHandler = ( - originalRequest: Request, - result: Response -) => Promise | Response; - type PipelineHooks = { before: PipelineHookFunction[]; }; @@ -26,11 +21,6 @@ export class Pipeline { #hooks: PipelineHooks = { before: [], }; - /** - * The handler accepts the *original* `Request` and result returned by the endpoint. - * It must return a `Response`. - */ - #endpointHandler?: EndpointResultHandler; /** * When creating a pipeline, an environment is mandatory. @@ -42,15 +32,6 @@ export class Pipeline { setEnvironment() {} - /** - * When rendering a route, an "endpoint" will a type that needs to be handled and transformed into a `Response`. - * - * Each consumer might have different needs; use this function to set up the handler. - */ - setEndpointHandler(handler: EndpointResultHandler) { - this.#endpointHandler = handler; - } - /** * A middleware function that will be called before each request. */ @@ -81,22 +62,7 @@ export class Pipeline { for (const hook of this.#hooks.before) { hook(renderContext, componentInstance); } - const result = await this.#tryRenderRoute( - renderContext, - this.env, - componentInstance, - this.#onRequest - ); - if (renderContext.route.type === 'endpoint') { - if (!this.#endpointHandler) { - throw new Error( - 'You created a pipeline that does not know how to handle the result coming from an endpoint.' - ); - } - return this.#endpointHandler(renderContext.request, result); - } else { - return result; - } + return await this.#tryRenderRoute(renderContext, this.env, componentInstance, this.#onRequest); } /** diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index 6ab297a5ee22..e05bfe346d61 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -194,39 +194,90 @@ function isSemanticallyEqualSegment(segmentA: RoutePart[], segmentB: RoutePart[] * The definition of "alphabetically" is dependent on the default locale of the running system. */ function routeComparator(a: ManifestRouteData, b: ManifestRouteData) { - // For sorting purposes, an index route is considered to have one more segment than the URL it represents. - const aLength = a.isIndex ? a.segments.length + 1 : a.segments.length; - const bLength = b.isIndex ? b.segments.length + 1 : b.segments.length; + const commonLength = Math.min(a.segments.length, b.segments.length); - // Sort more specific routes before less specific routes - if (aLength !== bLength) { - return aLength > bLength ? -1 : 1; + for (let index = 0; index < commonLength; index++) { + const aSegment = a.segments[index]; + const bSegment = b.segments[index]; + + const aIsStatic = aSegment.every((part) => !part.dynamic && !part.spread); + const bIsStatic = bSegment.every((part) => !part.dynamic && !part.spread); + + if (aIsStatic && bIsStatic) { + // Both segments are static, they are sorted alphabetically if they are different + const aContent = aSegment.map((part) => part.content).join(''); + const bContent = bSegment.map((part) => part.content).join(''); + + if (aContent !== bContent) { + return aContent.localeCompare(bContent); + } + } + + // Sort static routes before dynamic routes + if (aIsStatic !== bIsStatic) { + return aIsStatic ? -1 : 1; + } + + const aHasSpread = aSegment.some((part) => part.spread); + const bHasSpread = bSegment.some((part) => part.spread); + + // Sort dynamic routes with rest parameters after dynamic routes with single parameters + // (also after static, but that is already covered by the previous condition) + if (aHasSpread !== bHasSpread) { + return aHasSpread ? 1 : -1; + } } - const aIsStatic = a.segments.every((segment) => - segment.every((part) => !part.dynamic && !part.spread) - ); - const bIsStatic = b.segments.every((segment) => - segment.every((part) => !part.dynamic && !part.spread) - ); + // Special case to have `/[foo].astro` be equivalent to `/[foo]/index.astro` + // when compared against `/[foo]/[...rest].astro`. + if (Math.abs(a.segments.length - b.segments.length) === 1) { + const aEndsInRest = a.segments.at(-1)?.some((part) => part.spread); + const bEndsInRest = b.segments.at(-1)?.some((part) => part.spread); + + // Routes with rest parameters are less specific than their parent route. + // For example, `/foo/[...bar]` is sorted after `/foo`. + + if (a.segments.length > b.segments.length && !bEndsInRest) { + return 1; + } - // Sort static routes before dynamic routes - if (aIsStatic !== bIsStatic) { - return aIsStatic ? -1 : 1; + if (b.segments.length > a.segments.length && !aEndsInRest) { + return -1; + } } - const aHasSpread = a.segments.some((segment) => segment.some((part) => part.spread)); - const bHasSpread = b.segments.some((segment) => segment.some((part) => part.spread)); + if (a.isIndex !== b.isIndex) { + // Index pages are lower priority than other static segments in the same prefix. + // They match the path up to their parent, but are more specific than the parent. + // For example: + // - `/foo/index.astro` is sorted before `/foo` + // - `/foo/index.astro` is sorted before `/foo/[bar].astro` + // - `/[...foo]/index.astro` is sorted after `/[...foo]/bar.astro` + + if (a.isIndex) { + const followingBSegment = b.segments.at(a.segments.length); + const followingBSegmentIsStatic = followingBSegment?.every( + (part) => !part.dynamic && !part.spread + ); + + return followingBSegmentIsStatic ? 1 : -1; + } + + const followingASegment = a.segments.at(b.segments.length); + const followingASegmentIsStatic = followingASegment?.every( + (part) => !part.dynamic && !part.spread + ); - // Sort dynamic routes with rest parameters after dynamic routes with single parameters - // (also after static, but that is already covered by the previous condition) - if (aHasSpread !== bHasSpread) { - return aHasSpread ? 1 : -1; + return followingASegmentIsStatic ? -1 : 1; } - // Sort prerendered routes before non-prerendered routes - if (a.prerender !== b.prerender) { - return a.prerender ? -1 : 1; + // For sorting purposes, an index route is considered to have one more segment than the URL it represents. + const aLength = a.isIndex ? a.segments.length + 1 : a.segments.length; + const bLength = b.isIndex ? b.segments.length + 1 : b.segments.length; + + if (aLength !== bLength) { + // Routes are equal up to the smaller of the two lengths, so the longer route is more specific + return aLength > bLength ? -1 : 1; } // Sort endpoints before pages @@ -234,7 +285,7 @@ function routeComparator(a: ManifestRouteData, b: ManifestRouteData) { return a.type === 'endpoint' ? -1 : 1; } - // Sort alphabetically + // Both routes have segments with the same properties return a.route.localeCompare(b.route); } diff --git a/packages/astro/src/i18n/index.ts b/packages/astro/src/i18n/index.ts index 5a44d084092b..4b882b7829df 100644 --- a/packages/astro/src/i18n/index.ts +++ b/packages/astro/src/i18n/index.ts @@ -84,14 +84,14 @@ export function getLocaleAbsoluteUrl({ site, ...rest }: GetLocaleAbsoluteUrl) { } } -type GetLocalesBaseUrl = GetLocaleOptions & { +interface GetLocalesRelativeUrlList extends GetLocaleOptions { base: string; locales: Locales; trailingSlash: AstroConfig['trailingSlash']; format: AstroConfig['build']['format']; routing?: RoutingStrategies; defaultLocale: string; -}; +} export function getLocaleRelativeUrlList({ base, @@ -103,7 +103,7 @@ export function getLocaleRelativeUrlList({ normalizeLocale = false, routing = 'pathname-prefix-other-locales', defaultLocale, -}: GetLocalesBaseUrl) { +}: GetLocalesRelativeUrlList) { const locales = toPaths(_locales); return locales.map((locale) => { const pathsToJoin = [base, prependWith]; @@ -123,7 +123,11 @@ export function getLocaleRelativeUrlList({ }); } -export function getLocaleAbsoluteUrlList({ site, ...rest }: GetLocaleAbsoluteUrl) { +interface GetLocalesAbsoluteUrlList extends GetLocalesRelativeUrlList { + site?: string; +} + +export function getLocaleAbsoluteUrlList({ site, ...rest }: GetLocalesAbsoluteUrlList) { const locales = getLocaleRelativeUrlList(rest); return locales.map((locale) => { if (site) { @@ -139,7 +143,7 @@ export function getLocaleAbsoluteUrlList({ site, ...rest }: GetLocaleAbsoluteUrl * @param locale * @param locales */ -export function getPathByLocale(locale: string, locales: Locales) { +export function getPathByLocale(locale: string, locales: Locales): string { for (const loopLocale of locales) { if (typeof loopLocale === 'string') { if (loopLocale === locale) { @@ -153,6 +157,7 @@ export function getPathByLocale(locale: string, locales: Locales) { } } } + throw new Unreachable(); } /** @@ -161,19 +166,20 @@ export function getPathByLocale(locale: string, locales: Locales) { * @param path * @param locales */ -export function getLocaleByPath(path: string, locales: Locales): string | undefined { +export function getLocaleByPath(path: string, locales: Locales): string { for (const locale of locales) { if (typeof locale !== 'string') { if (locale.path === path) { // the first code is the one that user usually wants const code = locale.codes.at(0); + if (code === undefined) throw new Unreachable(); return code; } } else if (locale === path) { return locale; } } - return undefined; + throw new Unreachable(); } /** @@ -235,3 +241,14 @@ function peekCodePathToUse(locales: Locales, locale: string): undefined | string return undefined; } + +class Unreachable extends Error { + constructor() { + super( + 'Astro encountered an unexpected line of code.\n' + + 'In most cases, this is not your fault, but a bug in astro code.\n' + + "If there isn't one already, please create an issue.\n" + + 'https://astro.build/issues' + ); + } +} diff --git a/packages/astro/src/i18n/middleware.ts b/packages/astro/src/i18n/middleware.ts index 2ec796a05ef3..1298dcac8154 100644 --- a/packages/astro/src/i18n/middleware.ts +++ b/packages/astro/src/i18n/middleware.ts @@ -2,6 +2,7 @@ import { appendForwardSlash, joinPaths } from '@astrojs/internal-helpers/path'; import type { Locales, MiddlewareHandler, RouteData, SSRManifest } from '../@types/astro.js'; import type { PipelineHookFunction } from '../core/pipeline.js'; import { getPathByLocale, normalizeTheLocale } from './index.js'; +import { shouldAppendForwardSlash } from '../core/build/util.js'; const routeDataSymbol = Symbol.for('astro.routeData'); @@ -26,28 +27,18 @@ function pathnameHasLocale(pathname: string, locales: Locales): boolean { export function createI18nMiddleware( i18n: SSRManifest['i18n'], base: SSRManifest['base'], - trailingSlash: SSRManifest['trailingSlash'] -): MiddlewareHandler | undefined { - if (!i18n) { - return undefined; - } + trailingSlash: SSRManifest['trailingSlash'], + buildFormat: SSRManifest['buildFormat'] +): MiddlewareHandler { + if (!i18n) return (_, next) => next(); return async (context, next) => { - if (!i18n) { + const routeData: RouteData | undefined = Reflect.get(context.request, routeDataSymbol); + // If the route we're processing is not a page, then we ignore it + if (routeData?.type !== 'page' && routeData?.type !== 'fallback') { return await next(); } - const routeData = Reflect.get(context.request, routeDataSymbol); - if (routeData) { - // If the route we're processing is not a page, then we ignore it - if ( - (routeData as RouteData).type !== 'page' && - (routeData as RouteData).type !== 'fallback' - ) { - return await next(); - } - } - const url = context.url; const { locales, defaultLocale, fallback, routing } = i18n; const response = await next(); @@ -83,7 +74,7 @@ export function createI18nMiddleware( case 'pathname-prefix-always': { if (url.pathname === base + '/' || url.pathname === base) { - if (trailingSlash === 'always') { + if (shouldAppendForwardSlash(trailingSlash, buildFormat)) { return context.redirect(`${appendForwardSlash(joinPaths(base, i18n.defaultLocale))}`); } else { return context.redirect(`${joinPaths(base, i18n.defaultLocale)}`); diff --git a/packages/astro/src/i18n/vite-plugin-i18n.ts b/packages/astro/src/i18n/vite-plugin-i18n.ts index f01116f6cf41..856ce46a7415 100644 --- a/packages/astro/src/i18n/vite-plugin-i18n.ts +++ b/packages/astro/src/i18n/vite-plugin-i18n.ts @@ -1,69 +1,55 @@ import type * as vite from 'vite'; -import type { AstroSettings } from '../@types/astro.js'; +import type { AstroConfig, AstroSettings } from '../@types/astro.js'; +import { AstroError } from '../core/errors/errors.js'; +import { AstroErrorData } from '../core/errors/index.js'; const virtualModuleId = 'astro:i18n'; -const resolvedVirtualModuleId = '\0' + virtualModuleId; +const configId = 'astro-internal:i18n-config'; +const resolvedConfigId = `\0${configId}`; type AstroInternationalization = { settings: AstroSettings; }; +export interface I18nInternalConfig + extends Pick, + NonNullable, + Pick {} + export default function astroInternationalization({ settings, }: AstroInternationalization): vite.Plugin { + const { + base, + build: { format }, + i18n, + site, + trailingSlash, + } = settings.config; return { name: 'astro:i18n', enforce: 'pre', async resolveId(id) { if (id === virtualModuleId) { - return resolvedVirtualModuleId; + if (i18n === undefined) throw new AstroError(AstroErrorData.i18nNotEnabled); + return this.resolve('astro/virtual-modules/i18n.js'); } + if (id === configId) return resolvedConfigId; }, load(id) { - if (id === resolvedVirtualModuleId) { - return ` - import { - getLocaleRelativeUrl as _getLocaleRelativeUrl, - getLocaleRelativeUrlList as _getLocaleRelativeUrlList, - getLocaleAbsoluteUrl as _getLocaleAbsoluteUrl, - getLocaleAbsoluteUrlList as _getLocaleAbsoluteUrlList, - getPathByLocale as _getPathByLocale, - getLocaleByPath as _getLocaleByPath, - } from "astro/virtual-modules/i18n.js"; - - const base = ${JSON.stringify(settings.config.base)}; - const trailingSlash = ${JSON.stringify(settings.config.trailingSlash)}; - const format = ${JSON.stringify(settings.config.build.format)}; - const site = ${JSON.stringify(settings.config.site)}; - const i18n = ${JSON.stringify(settings.config.i18n)}; - - export const getRelativeLocaleUrl = (locale, path = "", opts) => _getLocaleRelativeUrl({ - locale, - path, - base, - trailingSlash, - format, - ...i18n, - ...opts - }); - export const getAbsoluteLocaleUrl = (locale, path = "", opts) => _getLocaleAbsoluteUrl({ - locale, - path, - base, - trailingSlash, - format, - site, - ...i18n, - ...opts - }); - - export const getRelativeLocaleUrlList = (path = "", opts) => _getLocaleRelativeUrlList({ - base, path, trailingSlash, format, ...i18n, ...opts }); - export const getAbsoluteLocaleUrlList = (path = "", opts) => _getLocaleAbsoluteUrlList({ base, path, trailingSlash, format, site, ...i18n, ...opts }); - - export const getPathByLocale = (locale) => _getPathByLocale(locale, i18n.locales); - export const getLocaleByPath = (path) => _getLocaleByPath(path, i18n.locales); - `; + if (id === resolvedConfigId) { + const { defaultLocale, locales, routing, fallback } = i18n!; + const config: I18nInternalConfig = { + base, + format, + site, + trailingSlash, + defaultLocale, + locales, + routing, + fallback, + }; + return `export default ${JSON.stringify(config)};`; } }, }; diff --git a/packages/astro/src/prerender/utils.ts b/packages/astro/src/prerender/utils.ts index b444352135d5..5cc627343028 100644 --- a/packages/astro/src/prerender/utils.ts +++ b/packages/astro/src/prerender/utils.ts @@ -6,7 +6,7 @@ export function isServerLikeOutput(config: AstroConfig) { } export function getPrerenderDefault(config: AstroConfig) { - return config.output === 'hybrid'; + return config.output !== 'server'; } /** diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/audit/a11y.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/a11y.ts index e004b70b5fb1..6eb4cd97b597 100644 --- a/packages/astro/src/runtime/client/dev-toolbar/apps/audit/a11y.ts +++ b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/a11y.ts @@ -208,7 +208,7 @@ const ariaAttributes = new Set( ); const ariaRoles = new Set( - 'alert alertdialog application article banner button cell checkbox columnheader combobox complementary contentinfo definition dialog directory document feed figure form grid gridcell group heading img link list listbox listitem log main marquee math menu menubar menuitem menuitemcheckbox menuitemradio navigation none note option presentation progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider spinbutton status tab tablist tabpanel textbox timer toolbar tooltip tree treegrid treeitem'.split( + 'alert alertdialog application article banner button cell checkbox columnheader combobox complementary contentinfo definition dialog directory document feed figure form grid gridcell group heading img link list listbox listitem log main marquee math menu menubar menuitem menuitemcheckbox menuitemradio navigation none note option presentation progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider spinbutton status switch tab tablist tabpanel textbox timer toolbar tooltip tree treegrid treeitem'.split( ' ' ) ); diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/settings.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/settings.ts index 7e7fa5e3a594..d6a2cbde8320 100644 --- a/packages/astro/src/runtime/client/dev-toolbar/apps/settings.ts +++ b/packages/astro/src/runtime/client/dev-toolbar/apps/settings.ts @@ -26,7 +26,7 @@ const settingsRows = [ settings.updateSetting('disableAppNotification', evt.currentTarget.checked); const action = evt.currentTarget.checked ? 'disabled' : 'enabled'; - settings.log(`App notification badges ${action}`); + settings.logger.verboseLog(`App notification badges ${action}`); } }, }, @@ -39,7 +39,7 @@ const settingsRows = [ if (evt.currentTarget instanceof HTMLInputElement) { settings.updateSetting('verbose', evt.currentTarget.checked); const action = evt.currentTarget.checked ? 'enabled' : 'disabled'; - settings.log(`Verbose logging ${action}`); + settings.logger.verboseLog(`Verbose logging ${action}`); } }, }, diff --git a/packages/astro/src/runtime/client/dev-toolbar/entrypoint.ts b/packages/astro/src/runtime/client/dev-toolbar/entrypoint.ts index a1d5484e3ac2..6a62b8416612 100644 --- a/packages/astro/src/runtime/client/dev-toolbar/entrypoint.ts +++ b/packages/astro/src/runtime/client/dev-toolbar/entrypoint.ts @@ -201,7 +201,7 @@ document.addEventListener('DOMContentLoaded', async () => { const iconContainer = document.createElement('div'); const iconElement = document.createElement('template'); - iconElement.innerHTML = getAppIcon(app.icon); + iconElement.innerHTML = app.icon ? getAppIcon(app.icon) : '?'; iconContainer.append(iconElement.content.cloneNode(true)); const notification = document.createElement('div'); diff --git a/packages/astro/src/runtime/client/dev-toolbar/settings.ts b/packages/astro/src/runtime/client/dev-toolbar/settings.ts index 6e3656dca32c..ee7386d4f565 100644 --- a/packages/astro/src/runtime/client/dev-toolbar/settings.ts +++ b/packages/astro/src/runtime/client/dev-toolbar/settings.ts @@ -44,6 +44,13 @@ function getSettings() { return _settings; }, updateSetting, - log, + logger: { + log, + verboseLog: (message: string) => { + if (_settings.verbose) { + log(message); + } + }, + }, }; } diff --git a/packages/astro/src/runtime/client/dev-toolbar/toolbar.ts b/packages/astro/src/runtime/client/dev-toolbar/toolbar.ts index 8e1c714014d6..1c9257cf8897 100644 --- a/packages/astro/src/runtime/client/dev-toolbar/toolbar.ts +++ b/packages/astro/src/runtime/client/dev-toolbar/toolbar.ts @@ -126,6 +126,11 @@ export class AstroDevToolbar extends HTMLElement { outline-offset: -3px; } + #dev-bar #bar-container .item[data-app-error]:hover, #dev-bar #bar-container .item[data-app-error]:focus-visible { + cursor: not-allowed; + background: #ff252520; + } + #dev-bar .item:first-of-type { border-top-left-radius: 9999px; border-bottom-left-radius: 9999px; @@ -166,6 +171,10 @@ export class AstroDevToolbar extends HTMLElement { border-top: 5px solid #343841; } + #dev-bar .item[data-app-error] .icon { + opacity: 0.35; + } + #dev-bar .item:hover .item-tooltip, #dev-bar .item:not(.active):focus-visible .item-tooltip { transition: opacity 0.2s ease-in-out 200ms; opacity: 1; @@ -266,7 +275,7 @@ export class AstroDevToolbar extends HTMLElement { // Create app canvases this.apps.forEach(async (app) => { - if (settings.config.verbose) console.log(`Creating app canvas for ${app.id}`); + settings.logger.verboseLog(`Creating app canvas for ${app.id}`); const appCanvas = document.createElement('astro-dev-toolbar-app-canvas'); appCanvas.dataset.appId = app.id; this.shadowRoot?.append(appCanvas); @@ -353,24 +362,40 @@ export class AstroDevToolbar extends HTMLElement { const shadowRoot = this.getAppCanvasById(app.id)!.shadowRoot!; app.status = 'loading'; try { - if (settings.config.verbose) console.info(`Initializing app ${app.id}`); + settings.logger.verboseLog(`Initializing app ${app.id}`); await app.init?.(shadowRoot, app.eventTarget); app.status = 'ready'; if (import.meta.hot) { import.meta.hot.send(`${WS_EVENT_NAME}:${app.id}:initialized`); + // TODO: Remove in Astro 5.0 import.meta.hot.send(`${WS_EVENT_NAME_DEPRECATED}:${app.id}:initialized`); } } catch (e) { console.error(`Failed to init app ${app.id}, error: ${e}`); app.status = 'error'; + + if (import.meta.hot) { + import.meta.hot.send('astro:devtoolbar:error:init', { + app: app, + error: e instanceof Error ? e.stack : e, + }); + } + + const appButton = this.getAppButtonById(app.id); + const appTooltip = appButton?.querySelector('.item-tooltip'); + + if (appButton && appTooltip) { + appButton.toggleAttribute('data-app-error', true); + appTooltip.innerText = `Error initializing ${app.name}`; + } } } getAppTemplate(app: DevToolbarApp) { return ``; } @@ -385,6 +410,10 @@ export class AstroDevToolbar extends HTMLElement { ); } + getAppButtonById(id: string) { + return this.shadowRoot.querySelector(`[data-app-id="${id}"]`); + } + async toggleAppStatus(app: DevToolbarApp) { const activeApp = this.getActiveApp(); if (activeApp) { @@ -418,7 +447,7 @@ export class AstroDevToolbar extends HTMLElement { } app.active = newStatus ?? !app.active; - const mainBarButton = this.shadowRoot.querySelector(`[data-app-id="${app.id}"]`); + const mainBarButton = this.getAppButtonById(app.id); const moreBarButton = this.getAppCanvasById('astro:more')?.shadowRoot?.querySelector( `[data-app-id="${app.id}"]` ); diff --git a/packages/astro/src/runtime/server/endpoint.ts b/packages/astro/src/runtime/server/endpoint.ts index 851d9bf0906d..aec9c6a1be0e 100644 --- a/packages/astro/src/runtime/server/endpoint.ts +++ b/packages/astro/src/runtime/server/endpoint.ts @@ -33,16 +33,14 @@ export async function renderEndpoint( ? `One of the exported handlers is "all" (lowercase), did you mean to export 'ALL'?\n` : '') ); - // No handler found, so this should be a 404. Using a custom header - // to signal to the renderer that this is an internal 404 that should - // be handled by a custom 404 route if possible. - return new Response(null, { - status: 404, - headers: { - 'X-Astro-Response': 'Not-Found', - }, - }); + // No handler matching the verb found, so this should be a + // 404. Should be handled by 404.astro route if possible. + return new Response(null, { status: 404 }); } - return handler.call(mod, context); + const response = await handler.call(mod, context); + // Endpoints explicitly returning 404 or 500 response status should + // NOT be subject to rerouting to 404.astro or 500.astro. + response.headers.set('X-Astro-Reroute', 'no'); + return response; } diff --git a/packages/astro/src/virtual-modules/i18n.ts b/packages/astro/src/virtual-modules/i18n.ts index a55c1f6cfb59..d9d470431117 100644 --- a/packages/astro/src/virtual-modules/i18n.ts +++ b/packages/astro/src/virtual-modules/i18n.ts @@ -1 +1,183 @@ -export * from '../i18n/index.js'; +import * as I18nInternals from '../i18n/index.js'; +import type { I18nInternalConfig } from '../i18n/vite-plugin-i18n.js'; +export { normalizeTheLocale, toCodes, toPaths } from '../i18n/index.js'; +// @ts-expect-error +import config from 'astro-internal:i18n-config'; +const { trailingSlash, format, site, defaultLocale, locales, routing } = + config as I18nInternalConfig; +const base = import.meta.env.BASE_URL; + +export type GetLocaleOptions = I18nInternals.GetLocaleOptions; + +/** + * @param locale A locale + * @param path An optional path to add after the `locale`. + * @param options Customise the generated path + * + * Returns a _relative_ path with passed locale. + * + * ## Errors + * + * Throws an error if the locale doesn't exist in the list of locales defined in the configuration. + * + * ## Examples + * + * ```js + * import { getRelativeLocaleUrl } from "astro:i18n"; + * getRelativeLocaleUrl("es"); // /es + * getRelativeLocaleUrl("es", "getting-started"); // /es/getting-started + * getRelativeLocaleUrl("es_US", "getting-started", { prependWith: "blog" }); // /blog/es-us/getting-started + * getRelativeLocaleUrl("es_US", "getting-started", { prependWith: "blog", normalizeLocale: false }); // /blog/es_US/getting-started + * ``` + */ +export const getRelativeLocaleUrl = (locale: string, path?: string, options?: GetLocaleOptions) => + I18nInternals.getLocaleRelativeUrl({ + locale, + path, + base, + trailingSlash, + format, + defaultLocale, + locales, + routing, + ...options, + }); + +/** + * + * @param locale A locale + * @param path An optional path to add after the `locale`. + * @param options Customise the generated path + * + * Returns an absolute path with the passed locale. The behaviour is subject to change based on `site` configuration. + * If _not_ provided, the function will return a _relative_ URL. + * + * ## Errors + * + * Throws an error if the locale doesn't exist in the list of locales defined in the configuration. + * + * ## Examples + * + * If `site` is `https://example.com`: + * + * ```js + * import { getAbsoluteLocaleUrl } from "astro:i18n"; + * getAbsoluteLocaleUrl("es"); // https://example.com/es + * getAbsoluteLocaleUrl("es", "getting-started"); // https://example.com/es/getting-started + * getAbsoluteLocaleUrl("es_US", "getting-started", { prependWith: "blog" }); // https://example.com/blog/es-us/getting-started + * getAbsoluteLocaleUrl("es_US", "getting-started", { prependWith: "blog", normalizeLocale: false }); // https://example.com/blog/es_US/getting-started + * ``` + */ +export const getAbsoluteLocaleUrl = (locale: string, path = '', options?: GetLocaleOptions) => + I18nInternals.getLocaleAbsoluteUrl({ + locale, + path, + base, + trailingSlash, + format, + site, + defaultLocale, + locales, + routing, + ...options, + }); + +/** + * @param path An optional path to add after the `locale`. + * @param options Customise the generated path + * + * Works like `getRelativeLocaleUrl` but it emits the relative URLs for ALL locales: + */ +export const getRelativeLocaleUrlList = (path?: string, options?: GetLocaleOptions) => + I18nInternals.getLocaleRelativeUrlList({ + base, + path, + trailingSlash, + format, + defaultLocale, + locales, + routing, + ...options, + }); + +/** + * @param path An optional path to add after the `locale`. + * @param options Customise the generated path + * + * Works like `getAbsoluteLocaleUrl` but it emits the absolute URLs for ALL locales: + */ +export const getAbsoluteLocaleUrlList = (path?: string, options?: GetLocaleOptions) => + I18nInternals.getLocaleAbsoluteUrlList({ + site, + base, + path, + trailingSlash, + format, + defaultLocale, + locales, + routing, + ...options, + }); + +/** + * A function that return the `path` associated to a locale (defined as code). It's particularly useful in case you decide + * to use locales that are broken down in paths and codes. + * + * @param locale The code of the locale + * @returns The path associated to the locale + * + * ## Example + * + * ```js + * // astro.config.mjs + * + * export default defineConfig({ + * i18n: { + * locales: [ + * { codes: ["it", "it-VT"], path: "italiano" }, + * "es" + * ] + * } + * }) + * ``` + * + * ```js + * import { getPathByLocale } from "astro:i18n"; + * getPathByLocale("it"); // returns "italiano" + * getPathByLocale("it-VT"); // returns "italiano" + * getPathByLocale("es"); // returns "es" + * ``` + */ +export const getPathByLocale = (locale: string) => I18nInternals.getPathByLocale(locale, locales); + +/** + * A function that returns the preferred locale given a certain path. This is particularly useful if you configure a locale using + * `path` and `codes`. When you define multiple `code`, this function will return the first code of the array. + * + * Astro will treat the first code as the one that the user prefers. + * + * @param path The path that maps to a locale + * @returns The path associated to the locale + * + * ## Example + * + * ```js + * // astro.config.mjs + * + * export default defineConfig({ + * i18n: { + * locales: [ + * { codes: ["it-VT", "it"], path: "italiano" }, + * "es" + * ] + * } + * }) + * ``` + * + * ```js + * import { getLocaleByPath } from "astro:i18n"; + * getLocaleByPath("italiano"); // returns "it-VT" because that's the first code configured + * getLocaleByPath("es"); // returns "es" + * ``` + */ +export const getLocaleByPath = (path: string) => I18nInternals.getLocaleByPath(path, locales); diff --git a/packages/astro/src/vite-plugin-astro-server/devPipeline.ts b/packages/astro/src/vite-plugin-astro-server/devPipeline.ts index d9758f4783f2..409851eafdc5 100644 --- a/packages/astro/src/vite-plugin-astro-server/devPipeline.ts +++ b/packages/astro/src/vite-plugin-astro-server/devPipeline.ts @@ -35,7 +35,6 @@ export default class DevPipeline extends Pipeline { this.#devLogger = logger; this.#settings = settings; this.#loader = loader; - this.setEndpointHandler(this.#handleEndpointResult); } clearRouteCache() { @@ -88,9 +87,5 @@ export default class DevPipeline extends Pipeline { }); } - async #handleEndpointResult(_: Request, response: Response): Promise { - return response; - } - async handleFallback() {} } diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index 67aef0babadd..4bbd85969e8a 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -125,6 +125,7 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest } return { trailingSlash: settings.config.trailingSlash, + buildFormat: settings.config.build.format, compressHTML: settings.config.compressHTML, assets: new Set(), entryModules: {}, diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index e24b7ca26774..67a2a4baa3af 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -308,7 +308,12 @@ export async function handleRoute({ const onRequest = middleware?.onRequest as MiddlewareHandler | undefined; if (config.i18n) { - const i18Middleware = createI18nMiddleware(config.i18n, config.base, config.trailingSlash); + const i18Middleware = createI18nMiddleware( + config.i18n, + config.base, + config.trailingSlash, + config.build.format + ); if (i18Middleware) { if (onRequest) { diff --git a/packages/astro/src/vite-plugin-dev-toolbar/vite-plugin-dev-toolbar.ts b/packages/astro/src/vite-plugin-dev-toolbar/vite-plugin-dev-toolbar.ts index 4e9526993ca6..022a8586c87e 100644 --- a/packages/astro/src/vite-plugin-dev-toolbar/vite-plugin-dev-toolbar.ts +++ b/packages/astro/src/vite-plugin-dev-toolbar/vite-plugin-dev-toolbar.ts @@ -4,7 +4,7 @@ import type { AstroPluginOptions } from '../@types/astro.js'; const VIRTUAL_MODULE_ID = 'astro:dev-toolbar'; const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID; -export default function astroDevToolbar({ settings }: AstroPluginOptions): vite.Plugin { +export default function astroDevToolbar({ settings, logger }: AstroPluginOptions): vite.Plugin { return { name: 'astro:dev-toolbar', config() { @@ -20,14 +20,55 @@ export default function astroDevToolbar({ settings }: AstroPluginOptions): vite. return resolvedVirtualModuleId; } }, + configureServer(server) { + server.ws.on('astro:devtoolbar:error:load', (args) => { + logger.error( + 'toolbar', + `Failed to load dev toolbar app from ${args.entrypoint}: ${args.error}` + ); + }); + + server.ws.on('astro:devtoolbar:error:init', (args) => { + logger.error( + 'toolbar', + `Failed to initialize dev toolbar app ${args.app.name} (${args.app.id}):\n${args.error}` + ); + }); + }, async load(id) { if (id === resolvedVirtualModuleId) { + // TODO: In Astro 5.0, we should change the addDevToolbarApp function to separate the logic from the app's metadata. + // That way, we can pass the app's data to the dev toolbar without having to load the app's entrypoint, which will allow + // for a better UI in the browser where we could still show the app's name and icon even if the app's entrypoint fails to load. + // ex: `addDevToolbarApp({ id: 'astro:dev-toolbar:app', name: 'App', icon: 'πŸš€', entrypoint: "./src/something.ts" })` return ` export const loadDevToolbarApps = async () => { - return [${settings.devToolbarApps - .map((plugin) => `(await import(${JSON.stringify(plugin)})).default`) - .join(',')}]; + return (await Promise.all([${settings.devToolbarApps + .map((plugin) => `safeLoadPlugin(${JSON.stringify(plugin)})`) + .join(',')}])).filter(app => app); }; + + async function safeLoadPlugin(entrypoint) { + try { + const app = (await import(/* @vite-ignore */ entrypoint)).default; + + if (typeof app !== 'object' || !app.id || !app.name) { + throw new Error("Apps must default export an object with an id, and a name."); + } + + return app; + } catch (err) { + console.error(\`Failed to load dev toolbar app from \${entrypoint}: \${err.message}\`); + + if (import.meta.hot) { + import.meta.hot.send('astro:devtoolbar:error:load', { entrypoint: entrypoint, error: err.message }) + } + + return undefined; + } + + return undefined; + } `; } }, diff --git a/packages/astro/src/vite-plugin-env/index.ts b/packages/astro/src/vite-plugin-env/index.ts index 7d91b3552491..2e16cc5bf337 100644 --- a/packages/astro/src/vite-plugin-env/index.ts +++ b/packages/astro/src/vite-plugin-env/index.ts @@ -14,6 +14,9 @@ const importMetaEnvOnlyRe = /\bimport\.meta\.env\b(?!\.)/; // Match valid JS variable names (identifiers), which accepts most alphanumeric characters, // except that the first character cannot be a number. const isValidIdentifierRe = /^[_$a-zA-Z][_$a-zA-Z0-9]*$/; +// Match `export const prerender = import.meta.env.*` since `vite=plugin-scanner` requires +// the `import.meta.env.*` to always be replaced. +const exportConstPrerenderRe = /\bexport\s+const\s+prerender\s*=\s*import\.meta\.env\.(.+?)\b/; function getPrivateEnv( viteConfig: vite.ResolvedConfig, @@ -156,6 +159,7 @@ export default function envVitePlugin({ settings }: EnvPluginOptions): vite.Plug // In dev, we can assign the private env vars to `import.meta.env` directly for performance if (isDev) { const s = new MagicString(source); + if (!devImportMetaEnvPrepend) { devImportMetaEnvPrepend = `Object.assign(import.meta.env,{`; for (const key in privateEnv) { @@ -164,6 +168,16 @@ export default function envVitePlugin({ settings }: EnvPluginOptions): vite.Plug devImportMetaEnvPrepend += '});'; } s.prepend(devImportMetaEnvPrepend); + + // EDGE CASE: We need to do a static replacement for `export const prerender` for `vite-plugin-scanner` + s.replace(exportConstPrerenderRe, (m, key) => { + if (privateEnv[key] != null) { + return `export const prerender = ${privateEnv[key]}`; + } else { + return m; + } + }); + return { code: s.toString(), map: s.generateMap({ hires: 'boundary' }), diff --git a/packages/astro/src/vite-plugin-html/transform/escape.ts b/packages/astro/src/vite-plugin-html/transform/escape.ts index 1c250d43dc94..d55233adca34 100644 --- a/packages/astro/src/vite-plugin-html/transform/escape.ts +++ b/packages/astro/src/vite-plugin-html/transform/escape.ts @@ -3,19 +3,25 @@ import type MagicString from 'magic-string'; import type { Plugin } from 'unified'; import { visit } from 'unist-util-visit'; -import { escape, needsEscape, replaceAttribute } from './utils.js'; +import { escapeTemplateLiteralCharacters, needsEscape, replaceAttribute } from './utils.js'; const rehypeEscape: Plugin<[{ s: MagicString }], Root> = ({ s }) => { return (tree) => { visit(tree, (node: Root | RootContent) => { if (node.type === 'text' || node.type === 'comment') { if (needsEscape(node.value)) { - s.overwrite(node.position!.start.offset!, node.position!.end.offset!, escape(node.value)); + s.overwrite( + node.position!.start.offset!, + node.position!.end.offset!, + escapeTemplateLiteralCharacters(node.value) + ); } } else if (node.type === 'element') { - for (const [key, value] of Object.entries(node.properties ?? {})) { - const newKey = needsEscape(key) ? escape(key) : key; - const newValue = needsEscape(value) ? escape(value) : value; + if (!node.properties) return; + for (let [key, value] of Object.entries(node.properties)) { + key = key.replace(/([A-Z])/g, '-$1').toLowerCase(); + const newKey = needsEscape(key) ? escapeTemplateLiteralCharacters(key) : key; + const newValue = needsEscape(value) ? escapeTemplateLiteralCharacters(value) : value; if (newKey === key && newValue === value) continue; replaceAttribute(s, node, key, value === '' ? newKey : `${newKey}="${newValue}"`); } diff --git a/packages/astro/src/vite-plugin-html/transform/slots.ts b/packages/astro/src/vite-plugin-html/transform/slots.ts index a549a48c9121..7969db3831b5 100644 --- a/packages/astro/src/vite-plugin-html/transform/slots.ts +++ b/packages/astro/src/vite-plugin-html/transform/slots.ts @@ -3,7 +3,7 @@ import type { Plugin } from 'unified'; import type MagicString from 'magic-string'; import { visit } from 'unist-util-visit'; -import { escape } from './utils.js'; +import { escapeTemplateLiteralCharacters } from './utils.js'; const rehypeSlots: Plugin<[{ s: MagicString }], Root> = ({ s }) => { return (tree, file) => { @@ -18,7 +18,11 @@ const rehypeSlots: Plugin<[{ s: MagicString }], Root> = ({ s }) => { const text = file.value .slice(first.position?.start.offset ?? 0, last.position?.end.offset ?? 0) .toString(); - s.overwrite(start, end, `\${${SLOT_PREFIX}["${name}"] ?? \`${escape(text).trim()}\`}`); + s.overwrite( + start, + end, + `\${${SLOT_PREFIX}["${name}"] ?? \`${escapeTemplateLiteralCharacters(text).trim()}\`}` + ); } }); }; diff --git a/packages/astro/src/vite-plugin-html/transform/utils.ts b/packages/astro/src/vite-plugin-html/transform/utils.ts index 6d61e7532d3e..88cb226e5d88 100644 --- a/packages/astro/src/vite-plugin-html/transform/utils.ts +++ b/packages/astro/src/vite-plugin-html/transform/utils.ts @@ -15,15 +15,46 @@ export function replaceAttribute(s: MagicString, node: Element, key: string, new const token = tokens[0].replace(/([^>])(\>[\s\S]*$)/gim, '$1'); if (token.trim() === key) { const end = start + key.length; - s.overwrite(start, end, newValue); + return s.overwrite(start, end, newValue, { contentOnly: true }); } else { - const end = start + `${key}=${tokens[2]}${tokens[3]}${tokens[2]}`.length; - s.overwrite(start, end, newValue); + const length = token.length; + const end = start + length; + return s.overwrite(start, end, newValue, { contentOnly: true }); } } + +// Embedding in our own template literal expression requires escaping +// any meaningful template literal characters in the user's code! +const NEEDS_ESCAPE_RE = /[`\\]|\$\{/g; + export function needsEscape(value: any): value is string { - return typeof value === 'string' && (value.includes('`') || value.includes('${')); + // Reset the RegExp's global state + NEEDS_ESCAPE_RE.lastIndex = 0; + return typeof value === 'string' && NEEDS_ESCAPE_RE.test(value); } -export function escape(value: string) { - return value.replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); + +export function escapeTemplateLiteralCharacters(value: string) { + // Reset the RegExp's global state + NEEDS_ESCAPE_RE.lastIndex = 0; + + let char: string | undefined; + let startIndex = 0; + let segment = ''; + let text = ''; + + // Rather than a naive `String.replace()`, we have to iterate through + // the raw contents to properly handle existing backslashes + while (([char] = NEEDS_ESCAPE_RE.exec(value) ?? [])) { + // Final loop when char === undefined, append trailing content + if (!char) { + text += value.slice(startIndex); + break; + } + const endIndex = NEEDS_ESCAPE_RE.lastIndex - char.length; + const prefix = segment === '\\' ? '' : '\\'; + segment = prefix + char; + text += value.slice(startIndex, endIndex) + segment; + startIndex = NEEDS_ESCAPE_RE.lastIndex; + } + return text; } diff --git a/packages/astro/src/vite-plugin-markdown/images.ts b/packages/astro/src/vite-plugin-markdown/images.ts index 1123c28784aa..1488ef6fc8c4 100644 --- a/packages/astro/src/vite-plugin-markdown/images.ts +++ b/packages/astro/src/vite-plugin-markdown/images.ts @@ -13,7 +13,10 @@ export function getMarkdownCodeForImages(imagePaths: MarkdownImagePath[], html: .map((entry) => { const rawUrl = JSON.stringify(entry.raw); return `{ - const regex = new RegExp('__ASTRO_IMAGE_="([^"]*' + ${rawUrl} + '[^"]*)"', 'g'); + const regex = new RegExp('__ASTRO_IMAGE_="([^"]*' + ${rawUrl.replace( + /[.*+?^${}()|[\]\\]/g, + '\\\\$&' + )} + '[^"]*)"', 'g'); let match; let occurrenceCounter = 0; while ((match = regex.exec(html)) !== null) { diff --git a/packages/astro/test/core-image.test.js b/packages/astro/test/core-image.test.js index 67a76ba8803c..fbaedd9a1e16 100644 --- a/packages/astro/test/core-image.test.js +++ b/packages/astro/test/core-image.test.js @@ -427,6 +427,15 @@ describe('astro:image', () => { expect($img.attr('src').startsWith('/_image')).to.equal(true); }); + it('Supports special characters in file name', async () => { + let res = await fixture.fetch('/specialChars'); + let html = await res.text(); + $ = cheerio.load(html); + + let $img = $('img'); + expect($img.attr('src').startsWith('/_image')).to.equal(true); + }); + it('properly handles remote images', async () => { let res = await fixture.fetch('/httpImage'); let html = await res.text(); diff --git a/packages/astro/test/fixtures/core-image/src/assets/c++.png b/packages/astro/test/fixtures/core-image/src/assets/c++.png new file mode 100644 index 000000000000..25c97b7cb5f9 Binary files /dev/null and b/packages/astro/test/fixtures/core-image/src/assets/c++.png differ diff --git a/packages/astro/test/fixtures/core-image/src/pages/specialChars.md b/packages/astro/test/fixtures/core-image/src/pages/specialChars.md new file mode 100644 index 000000000000..f3177cff75ce --- /dev/null +++ b/packages/astro/test/fixtures/core-image/src/pages/specialChars.md @@ -0,0 +1,3 @@ +![C++](../assets/c++.png) + +Image with special characters in file name worked. diff --git a/packages/astro/test/fixtures/html-escape-complex/package.json b/packages/astro/test/fixtures/html-escape-complex/package.json new file mode 100644 index 000000000000..f37957dd1f0b --- /dev/null +++ b/packages/astro/test/fixtures/html-escape-complex/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/html-escape-bug", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/html-escape-complex/src/pages/index.html b/packages/astro/test/fixtures/html-escape-complex/src/pages/index.html new file mode 100644 index 000000000000..7399304836d8 --- /dev/null +++ b/packages/astro/test/fixtures/html-escape-complex/src/pages/index.html @@ -0,0 +1,31 @@ + + + + + + + + Astro + + + +
+
+
+
+
+
+ + + + + diff --git a/packages/astro/test/html-escape-complex.test.js b/packages/astro/test/html-escape-complex.test.js new file mode 100644 index 000000000000..beed15501b96 --- /dev/null +++ b/packages/astro/test/html-escape-complex.test.js @@ -0,0 +1,50 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import { loadFixture } from './test-utils.js'; + +describe('HTML Escape (Complex)', () => { + let fixture; + /** @type {string} */ + let input; + /** @type {string} */ + let output; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/html-escape-complex/', + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + // readFile operates relative to `dist` + input = await fixture.readFile('../src/pages/index.html'); + output = await fixture.readFile('./index.html'); + }); + + it('respects complex escape sequences in attributes', async () => { + const $in = cheerio.load(input); + const $out = cheerio.load(output); + for (const char of 'abcdef'.split('')) { + const attrIn = $in('#' + char).attr('data-attr'); + const attrOut = $out('#' + char).attr('data-attr'); + expect(attrOut).to.equal(attrIn); + } + }); + + it('respects complex escape sequences in