diff --git a/.changeset/three-olives-reflect.md b/.changeset/three-olives-reflect.md new file mode 100644 index 000000000000..5f8ea9f2b166 --- /dev/null +++ b/.changeset/three-olives-reflect.md @@ -0,0 +1,24 @@ +--- +'astro': minor +--- + +Adds a new `codegenDir` URL to integrations `astro:config:setup` hook + +In 4.14, we introduced the `injectTypes` utility on the `astro:config:done` hook. It allows to create `.d.ts` files and make their types available to users projects automatically. Under the hood, it creates a file in `/.astro/integrations/`. + +While `.astro` 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/` is now exposed in the `astro:config:setup` hook as `codegenDir`. It allows you to have a dedicated folder, avoiding conflicts with another integration or Astro. This directory is always created before any hook runs 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': ({ codegenDir }) => { + 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 bf3213901517..6a0e0045fb63 100644 --- a/packages/astro/src/content/content-layer.ts +++ b/packages/astro/src/content/content-layer.ts @@ -7,6 +7,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, @@ -213,14 +214,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); @@ -232,7 +229,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..1bd6c21651b6 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({ @@ -151,6 +155,9 @@ export async function runHookConfigSetup({ for (let i = 0; i < updatedConfig.integrations.length; i++) { const integration = updatedConfig.integrations[i]; + const codegenDir = new URL(normalizeCodegenDir(integration.name), settings.dotAstroDir); + await fs.promises.mkdir(codegenDir, { recursive: true }); + /** * By making integration hooks optional, Astro can now ignore null or undefined Integrations * instead of giving an internal error most people can't read @@ -170,6 +177,7 @@ export async function runHookConfigSetup({ config: updatedConfig, command, isRestart, + codegenDir, addRenderer(renderer: AstroRenderer) { if (!renderer.name) { throw new Error(`Integration ${bold(integration.name)} has an unnamed renderer.`); 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..9621f750e27d 100644 --- a/packages/astro/src/types/public/integrations.ts +++ b/packages/astro/src/types/public/integrations.ts @@ -168,6 +168,7 @@ export interface BaseIntegrationHooks { config: AstroConfig; command: 'dev' | 'build' | 'preview' | 'sync'; isRestart: boolean; + codegenDir: URL; updateConfig: (newConfig: DeepPartial) => AstroConfig; addRenderer: (renderer: AstroRenderer) => void; addWatchFile: (path: URL | string) => void; 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-_____./'); +});