diff --git a/.changeset/good-carpets-confess.md b/.changeset/good-carpets-confess.md new file mode 100644 index 000000000000..c7095d3aab60 --- /dev/null +++ b/.changeset/good-carpets-confess.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Do not add base path to a hoisted script body diff --git a/.changeset/stupid-shoes-complain.md b/.changeset/stupid-shoes-complain.md new file mode 100644 index 000000000000..09669cf158af --- /dev/null +++ b/.changeset/stupid-shoes-complain.md @@ -0,0 +1,7 @@ +--- +'astro': minor +--- + +Correctly handle spaces and capitalization in `src/content/` file names. This introduces github-slugger for slug generation to ensure slugs are usable by `getStaticPaths`. Changes: +- Resolve spaces and capitalization: `collection/Entry With Spaces.md` becomes `collection/entry-with-spaces`. +- Truncate `/index` paths to base URL: `collection/index.md` becomes `collection` diff --git a/.changeset/yellow-gifts-complain.md b/.changeset/yellow-gifts-complain.md new file mode 100644 index 000000000000..f220d26c829d --- /dev/null +++ b/.changeset/yellow-gifts-complain.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fix `import.meta.env.DEV` always being set to `true` when using Content Collections diff --git a/packages/astro/package.json b/packages/astro/package.json index f3beb7fa82b4..395e4326052b 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -102,6 +102,7 @@ "postbuild": "astro-scripts copy \"src/**/*.astro\"", "benchmark": "node test/benchmark/dev.bench.js && node test/benchmark/build.bench.js", "test:unit": "mocha --exit --timeout 30000 ./test/units/**/*.test.js", + "test:unit:match": "mocha --exit --timeout 30000 ./test/units/**/*.test.js -g", "test": "pnpm run test:unit && mocha --exit --timeout 20000 --ignore **/lit-element.test.js && mocha --timeout 20000 **/lit-element.test.js", "test:match": "mocha --timeout 20000 -g", "test:e2e": "playwright test", @@ -137,7 +138,7 @@ "estree-walker": "^3.0.1", "execa": "^6.1.0", "fast-glob": "^3.2.11", - "github-slugger": "^1.4.0", + "github-slugger": "^2.0.0", "gray-matter": "^4.0.3", "html-entities": "^2.3.3", "html-escaper": "^3.0.3", diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts index 9b9f925b899b..667a667c2698 100644 --- a/packages/astro/src/content/types-generator.ts +++ b/packages/astro/src/content/types-generator.ts @@ -13,17 +13,14 @@ import { ContentObservable, ContentPaths, getContentPaths, + getEntryInfo, loadContentConfig, + NoCollectionError, } from './utils.js'; type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'; type RawContentEvent = { name: ChokidarEvent; entry: string }; type ContentEvent = { name: ChokidarEvent; entry: URL }; -type EntryInfo = { - id: string; - slug: string; - collection: string; -}; export type GenerateContentTypes = { init(): Promise; @@ -123,13 +120,13 @@ export async function createContentTypesGenerator({ return { shouldGenerateTypes: true }; } - const entryInfo = getEntryInfo({ - entry: event.entry, - contentDir: contentPaths.contentDir, - }); - // Not a valid `src/content/` entry. Silently return. - if (entryInfo instanceof Error) return { shouldGenerateTypes: false }; if (fileType === 'unknown') { + const entryInfo = getEntryInfo({ + entry: event.entry, + contentDir: contentPaths.contentDir, + // Allow underscore `_` files outside collection directories + allowFilesOutsideCollection: true, + }); if (entryInfo.id.startsWith('_') && (event.name === 'add' || event.name === 'change')) { // Silently ignore `_` files. return { shouldGenerateTypes: false }; @@ -140,7 +137,11 @@ export async function createContentTypesGenerator({ }; } } - if (entryInfo.collection === '.') { + const entryInfo = getEntryInfo({ + entry: event.entry, + contentDir: contentPaths.contentDir, + }); + if (entryInfo instanceof NoCollectionError) { if (['info', 'warn'].includes(logLevel)) { warn( logging, @@ -256,24 +257,6 @@ function removeEntry(contentTypes: ContentTypes, collectionKey: string, entryKey delete contentTypes[collectionKey][entryKey]; } -export function getEntryInfo({ - entry, - contentDir, -}: Pick & { entry: URL }): EntryInfo | Error { - const rawRelativePath = path.relative(fileURLToPath(contentDir), fileURLToPath(entry)); - const rawCollection = path.dirname(rawRelativePath).split(path.sep).shift(); - if (!rawCollection) return new Error(); - - const rawId = path.relative(rawCollection, rawRelativePath); - const rawSlug = rawId.replace(path.extname(rawId), ''); - const res = { - id: normalizePath(rawId), - slug: normalizePath(rawSlug), - collection: normalizePath(rawCollection), - }; - return res; -} - export function getEntryType( entryPath: string, paths: ContentPaths diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index dcb0a63ca497..07f19faf0497 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -1,7 +1,9 @@ +import { slug as githubSlug } from 'github-slugger'; import matter from 'gray-matter'; import type fsMod from 'node:fs'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createServer, ErrorPayload as ViteErrorPayload, ViteDevServer } from 'vite'; +import { createServer, ErrorPayload as ViteErrorPayload, normalizePath, ViteDevServer } from 'vite'; import { z } from 'zod'; import { AstroSettings } from '../@types/astro.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; @@ -40,6 +42,12 @@ type Entry = { _internal: { rawData: string; filePath: string }; }; +export type EntryInfo = { + id: string; + slug: string; + collection: string; +}; + export const msg = { collectionConfigMissing: (collection: string) => `${collection} does not have a config. We suggest adding one for type safety!`, @@ -87,11 +95,49 @@ export async function getEntryData(entry: Entry, collectionConfig: CollectionCon return data; } -const flattenPath = (path: (string | number)[]) => path.join('.'); +export class NoCollectionError extends Error {} + +export function getEntryInfo( + params: Pick & { entry: URL; allowFilesOutsideCollection?: true } +): EntryInfo; +export function getEntryInfo({ + entry, + contentDir, + allowFilesOutsideCollection = false, +}: Pick & { entry: URL; allowFilesOutsideCollection?: boolean }): + | EntryInfo + | NoCollectionError { + const rawRelativePath = path.relative(fileURLToPath(contentDir), fileURLToPath(entry)); + const rawCollection = path.dirname(rawRelativePath).split(path.sep).shift(); + const isOutsideCollection = rawCollection === '..' || rawCollection === '.'; + + if (!rawCollection || (!allowFilesOutsideCollection && isOutsideCollection)) + return new NoCollectionError(); + + const rawId = path.relative(rawCollection, rawRelativePath); + const rawIdWithoutFileExt = rawId.replace(new RegExp(path.extname(rawId) + '$'), ''); + const rawSlugSegments = rawIdWithoutFileExt.split(path.sep); + + const slug = rawSlugSegments + // Slugify each route segment to handle capitalization and spaces. + // Note: using `slug` instead of `new Slugger()` means no slug deduping. + .map((segment) => githubSlug(segment)) + .join('/') + .replace(/\/index$/, ''); + + const res = { + id: normalizePath(rawId), + slug, + collection: normalizePath(rawCollection), + }; + return res; +} + +const flattenErrorPath = (errorPath: (string | number)[]) => errorPath.join('.'); const errorMap: z.ZodErrorMap = (error, ctx) => { if (error.code === 'invalid_type') { - const badKeyPath = JSON.stringify(flattenPath(error.path)); + const badKeyPath = JSON.stringify(flattenErrorPath(error.path)); if (error.received === 'undefined') { return { message: `${badKeyPath} is required.` }; } else { @@ -144,6 +190,7 @@ export async function loadContentConfig({ settings: AstroSettings; }): Promise { const contentPaths = getContentPaths({ srcDir: settings.config.srcDir }); + const nodeEnv = process.env.NODE_ENV; const tempConfigServer: ViteDevServer = await createServer({ root: fileURLToPath(settings.config.root), server: { middlewareMode: true, hmr: false }, @@ -160,6 +207,9 @@ export async function loadContentConfig({ return new NotFoundError('Failed to resolve content config.'); } finally { await tempConfigServer.close(); + // Reset NODE_ENV to initial value + // Vite's `createServer()` sets NODE_ENV to 'development'! + process.env.NODE_ENV = nodeEnv; } const config = contentConfigParser.safeParse(unparsedConfig); if (config.success) { diff --git a/packages/astro/src/content/vite-plugin-content-assets.ts b/packages/astro/src/content/vite-plugin-content-assets.ts index 71a220c86b54..a76dcead803c 100644 --- a/packages/astro/src/content/vite-plugin-content-assets.ts +++ b/packages/astro/src/content/vite-plugin-content-assets.ts @@ -12,7 +12,8 @@ import { STYLES_PLACEHOLDER, } from './consts.js'; -function isDelayedAsset(url: URL): boolean { +function isDelayedAsset(viteId: string): boolean { + const url = new URL(viteId, 'file://'); return ( url.searchParams.has(DELAYED_ASSET_FLAG) && contentFileExts.some((ext) => url.pathname.endsWith(ext)) @@ -30,10 +31,10 @@ export function astroDelayedAssetPlugin({ mode }: { mode: string }): Plugin { } }, load(id) { - const url = new URL(id, 'file://'); - if (isDelayedAsset(url)) { + if (isDelayedAsset(id)) { + const basePath = id.split('?')[0]; const code = ` - export { Content, getHeadings, _internal } from ${JSON.stringify(url.pathname)}; + export { Content, getHeadings, _internal } from ${JSON.stringify(basePath)}; export const collectedLinks = ${JSON.stringify(LINKS_PLACEHOLDER)}; export const collectedStyles = ${JSON.stringify(STYLES_PLACEHOLDER)}; `; @@ -42,14 +43,13 @@ export function astroDelayedAssetPlugin({ mode }: { mode: string }): Plugin { }, async transform(code, id, options) { if (!options?.ssr) return; - const url = new URL(id, 'file://'); - if (devModuleLoader && isDelayedAsset(url)) { - const { pathname } = url; - if (!devModuleLoader.getModuleById(pathname)?.ssrModule) { - await devModuleLoader.import(pathname); + if (devModuleLoader && isDelayedAsset(id)) { + const basePath = id.split('?')[0]; + if (!devModuleLoader.getModuleById(basePath)?.ssrModule) { + await devModuleLoader.import(basePath); } const { stylesMap, urls } = await getStylesForURL( - pathToFileURL(pathname), + pathToFileURL(basePath), devModuleLoader, 'development' ); diff --git a/packages/astro/src/content/vite-plugin-content-server.ts b/packages/astro/src/content/vite-plugin-content-server.ts index a56df3b421d5..5030ec0020b9 100644 --- a/packages/astro/src/content/vite-plugin-content-server.ts +++ b/packages/astro/src/content/vite-plugin-content-server.ts @@ -5,13 +5,11 @@ import { pathToFileURL } from 'node:url'; import type { Plugin } from 'vite'; import type { AstroSettings } from '../@types/astro.js'; import { info, LogOptions } from '../core/logger/core.js'; -import { prependForwardSlash } from '../core/path.js'; -import { escapeViteEnvReferences } from '../vite-plugin-utils/index.js'; +import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index.js'; import { contentFileExts, CONTENT_FLAG } from './consts.js'; import { createContentTypesGenerator, GenerateContentTypes, - getEntryInfo, getEntryType, } from './types-generator.js'; import { @@ -20,6 +18,7 @@ import { ContentPaths, getContentPaths, getEntryData, + getEntryInfo, getEntrySlug, parseFrontmatter, } from './utils.js'; @@ -109,8 +108,8 @@ export function astroContentServerPlugin({ { name: 'astro-content-flag-plugin', async load(id) { - const fileUrl = new URL(prependForwardSlash(id), 'file://'); - if (isContentFlagImport(fileUrl)) { + const { fileId } = getFileInfo(id, settings.config); + if (isContentFlagImport(id)) { const observable = contentConfigObserver.get(); let contentConfig: ContentConfig | undefined = observable.status === 'loaded' ? observable.config : undefined; @@ -128,19 +127,19 @@ export function astroContentServerPlugin({ }); }); } - const rawContents = await fs.promises.readFile(fileUrl, 'utf-8'); + const rawContents = await fs.promises.readFile(fileId, 'utf-8'); const { content: body, data: unparsedData, matter: rawData = '', - } = parseFrontmatter(rawContents, fileUrl.pathname); + } = parseFrontmatter(rawContents, fileId); const entryInfo = getEntryInfo({ - entry: fileUrl, + entry: pathToFileURL(fileId), contentDir: contentPaths.contentDir, }); if (entryInfo instanceof Error) return; - const _internal = { filePath: fileUrl.pathname, rawData }; + const _internal = { filePath: fileId, rawData }; const partialEntry = { data: unparsedData, body, _internal, ...entryInfo }; const collectionConfig = contentConfig?.collections[entryInfo.collection]; const data = collectionConfig @@ -157,7 +156,7 @@ export const slug = ${JSON.stringify(slug)}; export const body = ${JSON.stringify(body)}; export const data = ${devalue.uneval(data) /* TODO: reuse astro props serializer */}; export const _internal = { - filePath: ${JSON.stringify(fileUrl.pathname)}, + filePath: ${JSON.stringify(fileId)}, rawData: ${JSON.stringify(rawData)}, }; `); @@ -172,7 +171,7 @@ export const _internal = { ) { // Content modules depend on config, so we need to invalidate them. for (const modUrl of viteServer.moduleGraph.urlToModuleMap.keys()) { - if (isContentFlagImport(new URL(modUrl, 'file://'))) { + if (isContentFlagImport(modUrl)) { const mod = await viteServer.moduleGraph.getModuleByUrl(modUrl); if (mod) { viteServer.moduleGraph.invalidateModule(mod); @@ -183,7 +182,7 @@ export const _internal = { }); }, async transform(code, id) { - if (isContentFlagImport(new URL(id, 'file://'))) { + if (isContentFlagImport(id)) { // Escape before Rollup internal transform. // Base on MUCH trial-and-error, inspired by MDX integration 2-step transform. return { code: escapeViteEnvReferences(code) }; @@ -193,6 +192,7 @@ export const _internal = { ]; } -function isContentFlagImport({ searchParams, pathname }: Pick) { +function isContentFlagImport(viteId: string) { + const { pathname, searchParams } = new URL(viteId, 'file://'); return searchParams.has(CONTENT_FLAG) && contentFileExts.some((ext) => pathname.endsWith(ext)); } diff --git a/packages/astro/src/core/build/vite-plugin-ssr.ts b/packages/astro/src/core/build/vite-plugin-ssr.ts index 928e3820e9a7..9de0051fcf43 100644 --- a/packages/astro/src/core/build/vite-plugin-ssr.ts +++ b/packages/astro/src/core/build/vite-plugin-ssr.ts @@ -44,9 +44,7 @@ const _manifest = Object.assign(_deserializeManifest('${manifestReplace}'), { renderers: _main.renderers }); const _args = ${adapter.args ? JSON.stringify(adapter.args) : 'undefined'}; - export * from '${pagesVirtualModuleId}'; - ${ adapter.exports ? `const _exports = adapter.createExports(_manifest, _args); @@ -165,9 +163,11 @@ function buildManifest( for (const pageData of eachServerPageData(internals)) { const scripts: SerializedRouteInfo['scripts'] = []; if (pageData.hoistedScript) { + const hoistedValue = pageData.hoistedScript.value; + const value = hoistedValue.endsWith('.js') ? joinBase(hoistedValue) : hoistedValue; scripts.unshift( Object.assign({}, pageData.hoistedScript, { - value: joinBase(pageData.hoistedScript.value), + value, }) ); } diff --git a/packages/astro/test/content-collections.test.js b/packages/astro/test/content-collections.test.js index a66287080dac..934f50017b7b 100644 --- a/packages/astro/test/content-collections.test.js +++ b/packages/astro/test/content-collections.test.js @@ -59,7 +59,22 @@ describe('Content Collections', () => { 'columbia.md', 'endeavour.md', 'enterprise.md', - 'promo/launch-week.mdx', + // Spaces allowed in IDs + 'promo/launch week.mdx', + ]); + }); + + it('Handles spaces in `without config` slugs', async () => { + expect(json).to.haveOwnProperty('withoutConfig'); + expect(Array.isArray(json.withoutConfig)).to.equal(true); + + const slugs = json.withoutConfig.map((item) => item.slug); + expect(slugs).to.deep.equal([ + 'columbia', + 'endeavour', + 'enterprise', + // "launch week.mdx" is converted to "launch-week.mdx" + 'promo/launch-week', ]); }); diff --git a/packages/astro/test/fixtures/content-collections/src/content/without-config/promo/launch-week-styles.css b/packages/astro/test/fixtures/content-collections/src/content/without-config/promo/_launch-week-styles.css similarity index 100% rename from packages/astro/test/fixtures/content-collections/src/content/without-config/promo/launch-week-styles.css rename to packages/astro/test/fixtures/content-collections/src/content/without-config/promo/_launch-week-styles.css diff --git a/packages/astro/test/fixtures/content-collections/src/content/without-config/promo/launch-week.mdx b/packages/astro/test/fixtures/content-collections/src/content/without-config/promo/launch week.mdx similarity index 89% rename from packages/astro/test/fixtures/content-collections/src/content/without-config/promo/launch-week.mdx rename to packages/astro/test/fixtures/content-collections/src/content/without-config/promo/launch week.mdx index f7c4bac161cd..22ed07c43f0c 100644 --- a/packages/astro/test/fixtures/content-collections/src/content/without-config/promo/launch-week.mdx +++ b/packages/astro/test/fixtures/content-collections/src/content/without-config/promo/launch week.mdx @@ -5,7 +5,7 @@ publishedDate: 'Sat May 21 2022 00:00:00 GMT-0400 (Eastern Daylight Time)' tags: ['announcement'] --- -import './launch-week-styles.css'; +import './_launch-week-styles.css'; Join us for the space blog launch! diff --git a/packages/astro/test/ssr-hoisted-script.test.js b/packages/astro/test/ssr-hoisted-script.test.js index 7d31875ffdf5..49e1e7b2fae0 100644 --- a/packages/astro/test/ssr-hoisted-script.test.js +++ b/packages/astro/test/ssr-hoisted-script.test.js @@ -29,4 +29,24 @@ describe('Hoisted scripts in SSR', () => { const $ = cheerioLoad(html); expect($('script').length).to.equal(1); }); + + describe('base path', () => { + const base = '/hello'; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/ssr-hoisted-script/', + output: 'server', + adapter: testAdapter(), + base, + }); + await fixture.build(); + }); + + it('Inlined scripts get included without base path in the script', async () => { + const html = await fetchHTML('/hello/'); + const $ = cheerioLoad(html); + expect($('script').html()).to.equal('console.log("hello world");\n'); + }); + }); }); diff --git a/packages/astro/test/units/content-collections/get-entry-info.test.js b/packages/astro/test/units/content-collections/get-entry-info.test.js new file mode 100644 index 000000000000..9f413bbeee4a --- /dev/null +++ b/packages/astro/test/units/content-collections/get-entry-info.test.js @@ -0,0 +1,44 @@ +import { getEntryInfo } from '../../../dist/content/utils.js'; +import { expect } from 'chai'; + +describe('Content Collections - getEntryInfo', () => { + const contentDir = new URL('src/content/', import.meta.url); + + it('Returns correct entry info', () => { + const entry = new URL('blog/first-post.md', contentDir); + const info = getEntryInfo({ entry, contentDir }); + expect(info.id).to.equal('first-post.md'); + expect(info.slug).to.equal('first-post'); + expect(info.collection).to.equal('blog'); + }); + + it('Returns correct slug when spaces used', () => { + const entry = new URL('blog/first post.mdx', contentDir); + const info = getEntryInfo({ entry, contentDir }); + expect(info.slug).to.equal('first-post'); + }); + + it('Returns correct slug when nested directories used', () => { + const entry = new URL('blog/2021/01/01/index.md', contentDir); + const info = getEntryInfo({ entry, contentDir }); + expect(info.slug).to.equal('2021/01/01'); + }); + + it('Returns correct collection when nested directories used', () => { + const entry = new URL('blog/2021/01/01/index.md', contentDir); + const info = getEntryInfo({ entry, contentDir }); + expect(info.collection).to.equal('blog'); + }); + + it('Returns error when outside collection directory', () => { + const entry = new URL('blog.md', contentDir); + expect(getEntryInfo({ entry, contentDir }) instanceof Error).to.equal(true); + }); + + it('Silences error on `allowFilesOutsideCollection`', () => { + const entry = new URL('blog.md', contentDir); + const entryInfo = getEntryInfo({ entry, contentDir, allowFilesOutsideCollection: true }); + expect(entryInfo instanceof Error).to.equal(false); + expect(entryInfo.id).to.equal('blog.md'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8c4c37d11a2..9f88b60b56ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -439,7 +439,7 @@ importers: estree-walker: ^3.0.1 execa: ^6.1.0 fast-glob: ^3.2.11 - github-slugger: ^1.4.0 + github-slugger: ^2.0.0 gray-matter: ^4.0.3 html-entities: ^2.3.3 html-escaper: ^3.0.3 @@ -514,7 +514,7 @@ importers: estree-walker: 3.0.1 execa: 6.1.0 fast-glob: 3.2.12 - github-slugger: 1.5.0 + github-slugger: 2.0.0 gray-matter: 4.0.3 html-entities: 2.3.3 html-escaper: 3.0.3 @@ -9960,7 +9960,7 @@ packages: /@types/sax/1.2.4: resolution: {integrity: sha512-pSAff4IAxJjfAXUG6tFkO7dsSbTmf8CtUpfhhZ5VhkRpC4628tJhh3+V6H1E+/Gs9piSzYKT5yzHO5M4GG9jkw==} dependencies: - '@types/node': 17.0.45 + '@types/node': 18.11.9 dev: false /@types/scheduler/0.16.2: