diff --git a/.changeset/three-olives-reflect.md b/.changeset/three-olives-reflect.md new file mode 100644 index 000000000000..62fed8f283e2 --- /dev/null +++ b/.changeset/three-olives-reflect.md @@ -0,0 +1,25 @@ +--- +'astro': minor +--- + +Adds a new `createCodegenDir()` function to the `astro:config:setup` hook in the Integrations API + +In 4.14, we introduced the `injectTypes` utility on the `astro:config:done` hook. It can create `.d.ts` files and make their types available to user's projects automatically. Under the hood, it creates a file in `/.astro/integrations/`. + +While the `.astro` directory has always been the preferred place to write code generated files, it has also been prone to mistakes. For example, you can write a `.astro/types.d.ts` file, breaking Astro types. Or you can create a file that overrides a file created by another integration. + +In this release, `/.astro/integrations/` can now be retrieved in the `astro:config:setup` hook by calling `createCodegenDir()`. It allows you to have a dedicated folder, avoiding conflicts with another integration or Astro itself. This directory is created by calling this function so it's safe to write files to it directly: + +```js +import { writeFileSync } from 'node:fs' + +const integration = { + name: 'my-integration', + hooks: { + 'astro:config:setup': ({ createCodegenDir }) => { + const codegenDir = createCodegenDir() + writeFileSync(new URL('cache.json', codegenDir), '{}', 'utf-8') + } + } +} +``` diff --git a/packages/astro/src/actions/consts.ts b/packages/astro/src/actions/consts.ts index beb8c45b641d..6a55386d869a 100644 --- a/packages/astro/src/actions/consts.ts +++ b/packages/astro/src/actions/consts.ts @@ -1,6 +1,6 @@ export const VIRTUAL_MODULE_ID = 'astro:actions'; export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID; -export const ACTIONS_TYPES_FILE = 'astro/actions.d.ts'; +export const ACTIONS_TYPES_FILE = 'actions.d.ts'; export const VIRTUAL_INTERNAL_MODULE_ID = 'astro:internal-actions'; export const RESOLVED_VIRTUAL_INTERNAL_MODULE_ID = '\0astro:internal-actions'; export const NOOP_ACTIONS = '\0noop-actions'; diff --git a/packages/astro/src/content/consts.ts b/packages/astro/src/content/consts.ts index 71ef0344db88..51eb6b78ed02 100644 --- a/packages/astro/src/content/consts.ts +++ b/packages/astro/src/content/consts.ts @@ -33,10 +33,11 @@ export const CONTENT_FLAGS = [ CONTENT_MODULE_FLAG, ] as const; -export const CONTENT_TYPES_FILE = 'astro/content.d.ts'; - +export const CONTENT_TYPES_FILE = 'content.d.ts'; export const DATA_STORE_FILE = 'data-store.json'; -export const ASSET_IMPORTS_FILE = 'assets.mjs'; -export const MODULES_IMPORTS_FILE = 'modules.mjs'; +export const ASSET_IMPORTS_FILE = 'content-assets.mjs'; +export const MODULES_IMPORTS_FILE = 'content-modules.mjs'; +export const COLLECTIONS_MANIFEST_FILE = 'collections/collections.json'; +export const COLLECTIONS_DIR = 'collections/' export const CONTENT_LAYER_TYPE = 'content_layer'; diff --git a/packages/astro/src/content/content-layer.ts b/packages/astro/src/content/content-layer.ts index 2e092ae6150d..dcb7aca9fb9d 100644 --- a/packages/astro/src/content/content-layer.ts +++ b/packages/astro/src/content/content-layer.ts @@ -8,6 +8,7 @@ import type { AstroSettings } from '../types/astro.js'; import type { ContentEntryType, RefreshContentOptions } from '../types/public/content.js'; import { ASSET_IMPORTS_FILE, + COLLECTIONS_MANIFEST_FILE, CONTENT_LAYER_TYPE, DATA_STORE_FILE, MODULES_IMPORTS_FILE, @@ -214,14 +215,10 @@ export class ContentLayer { return collection.loader.load(context); }), ); - if (!existsSync(this.#settings.config.cacheDir)) { - await fs.mkdir(this.#settings.config.cacheDir, { recursive: true }); - } + await fs.mkdir(this.#settings.config.cacheDir, { recursive: true }); + await fs.mkdir(this.#settings.dotAstroDir, { recursive: true }); const cacheFile = getDataStoreFile(this.#settings); await this.#store.writeToDisk(cacheFile); - if (!existsSync(this.#settings.dotAstroDir)) { - await fs.mkdir(this.#settings.dotAstroDir, { recursive: true }); - } const assetImportsFile = new URL(ASSET_IMPORTS_FILE, this.#settings.dotAstroDir); await this.#store.writeAssetImports(assetImportsFile); const modulesImportsFile = new URL(MODULES_IMPORTS_FILE, this.#settings.dotAstroDir); @@ -233,7 +230,7 @@ export class ContentLayer { } async regenerateCollectionFileManifest() { - const collectionsManifest = new URL('collections/collections.json', this.#settings.dotAstroDir); + const collectionsManifest = new URL(COLLECTIONS_MANIFEST_FILE, this.#settings.dotAstroDir); this.#logger.debug('content', 'Regenerating collection file manifest'); if (existsSync(collectionsManifest)) { try { diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts index 9923a0c343d7..330c1973dad7 100644 --- a/packages/astro/src/content/types-generator.ts +++ b/packages/astro/src/content/types-generator.ts @@ -13,7 +13,12 @@ import type { Logger } from '../core/logger/core.js'; import { isRelativePath } from '../core/path.js'; import type { AstroSettings } from '../types/astro.js'; import type { ContentEntryType } from '../types/public/content.js'; -import { CONTENT_LAYER_TYPE, CONTENT_TYPES_FILE, VIRTUAL_MODULE_ID } from './consts.js'; +import { + COLLECTIONS_DIR, + CONTENT_LAYER_TYPE, + CONTENT_TYPES_FILE, + VIRTUAL_MODULE_ID, +} from './consts.js'; import { type CollectionConfig, type ContentConfig, @@ -428,10 +433,8 @@ async function writeContentFiles({ let contentTypesStr = ''; let dataTypesStr = ''; - const collectionSchemasDir = new URL('./collections/', settings.dotAstroDir); - if (!fs.existsSync(collectionSchemasDir)) { - fs.mkdirSync(collectionSchemasDir, { recursive: true }); - } + const collectionSchemasDir = new URL(COLLECTIONS_DIR, settings.dotAstroDir); + fs.mkdirSync(collectionSchemasDir, { recursive: true }); for (const [collection, config] of Object.entries(contentConfig?.collections ?? {})) { collectionEntryMap[JSON.stringify(collection)] ??= { @@ -568,12 +571,8 @@ async function writeContentFiles({ ); } - if (!fs.existsSync(settings.dotAstroDir)) { - fs.mkdirSync(settings.dotAstroDir, { recursive: true }); - } - const configPathRelativeToCacheDir = normalizeConfigPath( - new URL('astro', settings.dotAstroDir).pathname, + settings.dotAstroDir.pathname, contentPaths.config.url.pathname, ); @@ -591,9 +590,11 @@ async function writeContentFiles({ // If it's the first time, we inject types the usual way. sync() will handle creating files and references. If it's not the first time, we just override the dts content if (settings.injectedTypes.some((t) => t.filename === CONTENT_TYPES_FILE)) { - const filePath = fileURLToPath(new URL(CONTENT_TYPES_FILE, settings.dotAstroDir)); - await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); - await fs.promises.writeFile(filePath, typeTemplateContent, 'utf-8'); + await fs.promises.writeFile( + new URL(CONTENT_TYPES_FILE, settings.dotAstroDir), + typeTemplateContent, + 'utf-8', + ); } else { settings.injectedTypes.push({ filename: CONTENT_TYPES_FILE, diff --git a/packages/astro/src/core/dev/restart.ts b/packages/astro/src/core/dev/restart.ts index a19f56e8a6d7..92dcc28ae0b7 100644 --- a/packages/astro/src/core/dev/restart.ts +++ b/packages/astro/src/core/dev/restart.ts @@ -12,6 +12,7 @@ import { createSafeError } from '../errors/index.js'; import { formatErrorMessage } from '../messages.js'; import type { Container } from './container.js'; import { createContainer, startContainer } from './container.js'; +import { SETTINGS_FILE } from '../../preferences/constants.js'; async function createRestartedContainer( container: Container, @@ -50,7 +51,7 @@ function shouldRestartContainer( else { shouldRestart = configRE.test(normalizedChangedFile); const settingsPath = vite.normalizePath( - fileURLToPath(new URL('settings.json', settings.dotAstroDir)), + fileURLToPath(new URL(SETTINGS_FILE, settings.dotAstroDir)), ); if (settingsPath.endsWith(normalizedChangedFile)) { shouldRestart = settings.preferences.ignoreNextPreferenceReload ? false : true; diff --git a/packages/astro/src/env/constants.ts b/packages/astro/src/env/constants.ts index ac2c2c297ff6..220f63373c16 100644 --- a/packages/astro/src/env/constants.ts +++ b/packages/astro/src/env/constants.ts @@ -5,7 +5,7 @@ export const VIRTUAL_MODULES_IDS = { }; export const VIRTUAL_MODULES_IDS_VALUES = new Set(Object.values(VIRTUAL_MODULES_IDS)); -export const ENV_TYPES_FILE = 'astro/env.d.ts'; +export const ENV_TYPES_FILE = 'env.d.ts'; const PKG_BASE = new URL('../../', import.meta.url); export const MODULE_TEMPLATE_URL = new URL('templates/env.mjs', PKG_BASE); diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts index 5ac21c435a5f..55297de87a75 100644 --- a/packages/astro/src/integrations/hooks.ts +++ b/packages/astro/src/integrations/hooks.ts @@ -112,13 +112,17 @@ export function getToolbarServerCommunicationHelpers(server: ViteDevServer) { // Will match any invalid characters (will be converted to _). We only allow a-zA-Z0-9.-_ const SAFE_CHARS_RE = /[^\w.-]/g; +export function normalizeCodegenDir(integrationName: string): string { + return `./integrations/${integrationName.replace(SAFE_CHARS_RE, '_')}/`; +} + export function normalizeInjectedTypeFilename(filename: string, integrationName: string): string { if (!filename.endsWith('.d.ts')) { throw new Error( `Integration ${bold(integrationName)} is injecting a type that does not end with "${bold('.d.ts')}"`, ); } - return `./integrations/${integrationName.replace(SAFE_CHARS_RE, '_')}/${filename.replace(SAFE_CHARS_RE, '_')}`; + return `${normalizeCodegenDir(integrationName)}${filename.replace(SAFE_CHARS_RE, '_')}`; } export async function runHookConfigSetup({ @@ -234,6 +238,11 @@ export async function runHookConfigSetup({ ); updatedSettings.middlewares[order].push(entrypoint); }, + createCodegenDir: () => { + const codegenDir = new URL(normalizeCodegenDir(integration.name), settings.dotAstroDir); + fs.mkdirSync(codegenDir, { recursive: true }); + return codegenDir; + }, logger: integrationLogger, }; diff --git a/packages/astro/src/preferences/constants.ts b/packages/astro/src/preferences/constants.ts new file mode 100644 index 000000000000..108787a28bbb --- /dev/null +++ b/packages/astro/src/preferences/constants.ts @@ -0,0 +1 @@ +export const SETTINGS_FILE = 'settings.json'; diff --git a/packages/astro/src/preferences/store.ts b/packages/astro/src/preferences/store.ts index c999566e81b1..373ec88c165f 100644 --- a/packages/astro/src/preferences/store.ts +++ b/packages/astro/src/preferences/store.ts @@ -2,13 +2,14 @@ import fs from 'node:fs'; import path from 'node:path'; import dget from 'dlv'; import { dset } from 'dset'; +import { SETTINGS_FILE } from './constants.js'; export class PreferenceStore { private file: string; constructor( private dir: string, - filename = 'settings.json', + filename = SETTINGS_FILE, ) { this.file = path.join(this.dir, filename); } diff --git a/packages/astro/src/types/public/integrations.ts b/packages/astro/src/types/public/integrations.ts index 78c4104f1bd1..73a25f63caae 100644 --- a/packages/astro/src/types/public/integrations.ts +++ b/packages/astro/src/types/public/integrations.ts @@ -176,6 +176,7 @@ export interface BaseIntegrationHooks { addClientDirective: (directive: ClientDirectiveConfig) => void; addDevToolbarApp: (entrypoint: DevToolbarAppEntry) => void; addMiddleware: (mid: AstroIntegrationMiddleware) => void; + createCodegenDir: () => URL; logger: AstroIntegrationLogger; }) => void | Promise; 'astro:config:done': (options: { diff --git a/packages/astro/test/astro-sync.test.js b/packages/astro/test/astro-sync.test.js index f12fb5bc42ed..c8a2de49c5ac 100644 --- a/packages/astro/test/astro-sync.test.js +++ b/packages/astro/test/astro-sync.test.js @@ -123,15 +123,15 @@ describe('astro sync', () => { fixture.thenFileShouldExist('.astro/types.d.ts'); fixture.thenFileContentShouldInclude( '.astro/types.d.ts', - `/// `, + `/// `, ); - fixture.thenFileShouldExist('.astro/astro/content.d.ts'); + fixture.thenFileShouldExist('.astro/content.d.ts'); fixture.thenFileContentShouldInclude( - '.astro/astro/content.d.ts', + '.astro/content.d.ts', `declare module 'astro:content' {`, 'Types file does not include `astro:content` module declaration', ); - fixture.thenFileShouldBeValidTypescript('.astro/astro/content.d.ts'); + fixture.thenFileShouldBeValidTypescript('.astro/content.d.ts'); }); it('Writes types for empty collections', async () => { @@ -139,7 +139,7 @@ describe('astro sync', () => { fixture.clean(); await fixture.whenSyncing(); fixture.thenFileContentShouldInclude( - '.astro/astro/content.d.ts', + '.astro/content.d.ts', `"blog": Record { 'Types file does not include empty collection type', ); fixture.thenFileContentShouldInclude( - '.astro/astro/content.d.ts', + '.astro/content.d.ts', `"blogMeta": Record { fixture.thenFileShouldExist('.astro/types.d.ts'); fixture.thenFileContentShouldInclude( '.astro/types.d.ts', - `/// `, + `/// `, ); - fixture.thenFileShouldExist('.astro/astro/env.d.ts'); + fixture.thenFileShouldExist('.astro/env.d.ts'); fixture.thenFileContentShouldInclude( - '.astro/astro/env.d.ts', + '.astro/env.d.ts', `declare module 'astro:env/client' {`, 'Types file does not include `astro:env` module declaration', ); @@ -210,15 +210,15 @@ describe('astro sync', () => { fixture.thenFileShouldExist('.astro/types.d.ts'); fixture.thenFileContentShouldInclude( '.astro/types.d.ts', - `/// `, + `/// `, ); - fixture.thenFileShouldExist('.astro/astro/actions.d.ts'); + fixture.thenFileShouldExist('.astro/actions.d.ts'); fixture.thenFileContentShouldInclude( - '.astro/astro/actions.d.ts', + '.astro/actions.d.ts', `declare module "astro:actions" {`, 'Types file does not include `astro:actions` module declaration', ); - fixture.thenFileShouldBeValidTypescript('.astro/astro/actions.d.ts'); + fixture.thenFileShouldBeValidTypescript('.astro/actions.d.ts'); }); }); }); diff --git a/packages/astro/test/units/integrations/api.test.js b/packages/astro/test/units/integrations/api.test.js index 0a0233215061..f2365c006b39 100644 --- a/packages/astro/test/units/integrations/api.test.js +++ b/packages/astro/test/units/integrations/api.test.js @@ -2,6 +2,7 @@ import * as assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { validateSupportedFeatures } from '../../../dist/integrations/features-validation.js'; import { + normalizeCodegenDir, normalizeInjectedTypeFilename, runHookBuildSetup, runHookConfigSetup, @@ -12,6 +13,7 @@ const defaultConfig = { root: new URL('./', import.meta.url), srcDir: new URL('src/', import.meta.url), }; +const dotAstroDir = new URL('./.astro/', defaultConfig.root); describe('Integration API', () => { it('runHookBuildSetup should work', async () => { @@ -87,6 +89,7 @@ describe('Integration API', () => { }, ], }, + dotAstroDir, }, }); assert.equal(updatedSettings.config.site, site); @@ -122,6 +125,7 @@ describe('Integration API', () => { }, ], }, + dotAstroDir, }, }); assert.equal(updatedSettings.config.site, site); @@ -270,3 +274,7 @@ describe('normalizeInjectedTypeFilename', () => { './integrations/aA1-_____./types.d.ts', ); }); + +describe('normalizeCodegenDir', () => { + assert.equal(normalizeCodegenDir('aA1-*/_"~.'), './integrations/aA1-_____./'); +});