diff --git a/.changeset/serious-pumas-run.md b/.changeset/serious-pumas-run.md new file mode 100644 index 000000000000..e6f7c9af1af7 --- /dev/null +++ b/.changeset/serious-pumas-run.md @@ -0,0 +1,21 @@ +--- +'astro': minor +--- + +Adds support for Intellisense features (e.g. code completion, quick hints) for your content collection entries in compatible editors under the `experimental.contentIntellisense` flag. + +```js +import { defineConfig } from 'astro'; + +export default defineConfig({ + experimental: { + contentIntellisense: true + } +}) +``` + +When enabled, this feature will generate and add JSON schemas to the `.astro` directory in your project. These files can be used by the Astro language server to provide Intellisense inside content files (`.md`, `.mdx`, `.mdoc`). + +Note that at this time, this also require enabling the `astro.content-intellisense` option in your editor, or passing the `contentIntellisense: true` initialization parameter to the Astro language server for editors using it directly. + +See the [experimental content Intellisense docs](https://docs.astro.build/en/reference/configuration-reference/#experimentalcontentintellisense) for more information updates as this feature develops. diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 129d52f638a6..885db99e9824 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1,5 +1,3 @@ -import type { OutgoingHttpHeaders } from 'node:http'; -import type { AddressInfo } from 'node:net'; import type { MarkdownHeading, MarkdownVFile, @@ -9,6 +7,8 @@ import type { ShikiConfig, } from '@astrojs/markdown-remark'; import type * as babel from '@babel/core'; +import type { OutgoingHttpHeaders } from 'node:http'; +import type { AddressInfo } from 'node:net'; import type * as rollup from 'rollup'; import type * as vite from 'vite'; import type { @@ -79,7 +79,7 @@ export type { UnresolvedImageTransform, } from '../assets/types.js'; export type { RemotePattern } from '../assets/utils/remotePattern.js'; -export type { SSRManifest, AssetsPrefix } from '../core/app/types.js'; +export type { AssetsPrefix, SSRManifest } from '../core/app/types.js'; export type { AstroCookieGetOptions, AstroCookieSetOptions, @@ -2186,6 +2186,30 @@ export interface AstroUserConfig { */ serverIslands?: boolean; + /** + * @docs + * @name experimental.contentIntellisense + * @type {boolean} + * @default `false` + * @version 4.14.0 + * @description + * + * Enables Intellisense features (e.g. code completion, quick hints) for your content collection entries in compatible editors. + * + * When enabled, this feature will generate and add JSON schemas to the `.astro` directory in your project. These files can be used by the Astro language server to provide Intellisense inside content files (`.md`, `.mdx`, `.mdoc`). + * + * ```js + * { + * experimental: { + * contentIntellisense: true, + * }, + * } + * ``` + * + * To use this feature with the Astro VS Code extension, you must also enable the `astro.content-intellisense` option in your VS Code settings. For editors using the Astro language server directly, pass the `contentIntellisense: true` initialization parameter to enable this feature. + */ + contentIntellisense?: boolean; + /** * @docs * @name experimental.contentLayer diff --git a/packages/astro/src/content/content-layer.ts b/packages/astro/src/content/content-layer.ts index 2019211bb08c..9a6d4ed37542 100644 --- a/packages/astro/src/content/content-layer.ts +++ b/packages/astro/src/content/content-layer.ts @@ -230,6 +230,42 @@ export class ContentLayer { const modulesImportsFile = new URL(MODULES_IMPORTS_FILE, this.#settings.dotAstroDir); await this.#store.writeModuleImports(modulesImportsFile); logger.info('Synced content'); + if (this.#settings.config.experimental.contentIntellisense) { + await this.regenerateCollectionFileManifest(); + } + } + + async regenerateCollectionFileManifest() { + const collectionsManifest = new URL('collections/collections.json', this.#settings.dotAstroDir); + this.#logger.debug('content', 'Regenerating collection file manifest'); + if (existsSync(collectionsManifest)) { + try { + const collections = await fs.readFile(collectionsManifest, 'utf-8'); + const collectionsJson = JSON.parse(collections); + collectionsJson.entries ??= {}; + + for (const { hasSchema, name } of collectionsJson.collections) { + if (!hasSchema) { + continue; + } + const entries = this.#store.values(name); + if (!entries?.[0]?.filePath) { + continue; + } + for (const { filePath } of entries) { + if (!filePath) { + continue; + } + const key = new URL(filePath, this.#settings.config.root).href.toLowerCase(); + collectionsJson.entries[key] = name; + } + } + await fs.writeFile(collectionsManifest, JSON.stringify(collectionsJson, null, 2)); + } catch { + this.#logger.error('content', 'Failed to regenerate collection file manifest'); + } + } + this.#logger.debug('content', 'Regenerated collection file manifest'); } } diff --git a/packages/astro/src/content/data-store.ts b/packages/astro/src/content/data-store.ts index f60c94642e17..f32074357197 100644 --- a/packages/astro/src/content/data-store.ts +++ b/packages/astro/src/content/data-store.ts @@ -64,16 +64,16 @@ export class DataStore { this.#collections = new Map(); } - get(collectionName: string, key: string): T | undefined { + get(collectionName: string, key: string): T | undefined { return this.#collections.get(collectionName)?.get(String(key)); } - entries(collectionName: string): Array<[id: string, T]> { + entries(collectionName: string): Array<[id: string, T]> { const collection = this.#collections.get(collectionName) ?? new Map(); return [...collection.entries()]; } - values(collectionName: string): Array { + values(collectionName: string): Array { const collection = this.#collections.get(collectionName) ?? new Map(); return [...collection.values()]; } @@ -217,7 +217,7 @@ export default new Map([${exports.join(', ')}]); for (const [fileName, specifier] of this.#moduleImports) { lines.push(`['${fileName}', () => import('${specifier}')]`); } - const code = /* js */ ` + const code = ` export default new Map([\n${lines.join(',\n')}]); `; try { diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts index 2394f43fa631..6fa0db94beb2 100644 --- a/packages/astro/src/content/types-generator.ts +++ b/packages/astro/src/content/types-generator.ts @@ -1,10 +1,10 @@ +import glob from 'fast-glob'; +import { bold, cyan } from 'kleur/colors'; import type fsMod from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import glob from 'fast-glob'; -import { bold, cyan } from 'kleur/colors'; import { type ViteDevServer, normalizePath } from 'vite'; -import { z } from 'zod'; +import { z, type ZodSchema } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { printNode, zodToTs } from 'zod-to-ts'; import type { AstroSettings, ContentEntryType } from '../@types/astro.js'; @@ -12,9 +12,9 @@ import { AstroError } from '../core/errors/errors.js'; import { AstroErrorData } from '../core/errors/index.js'; import type { Logger } from '../core/logger/core.js'; import { isRelativePath } from '../core/path.js'; -import { CONTENT_LAYER_TYPE } from './consts.js'; -import { CONTENT_TYPES_FILE, VIRTUAL_MODULE_ID } from './consts.js'; +import { CONTENT_LAYER_TYPE, CONTENT_TYPES_FILE, VIRTUAL_MODULE_ID } from './consts.js'; import { + type CollectionConfig, type ContentConfig, type ContentObservable, type ContentPaths, @@ -358,12 +358,15 @@ function normalizeConfigPath(from: string, to: string) { return `"${isRelativePath(configPath) ? '' : './'}${normalizedPath}"` as const; } -async function typeForCollection( - collection: ContentConfig['collections'][T] | undefined, +const schemaCache = new Map(); + +async function getContentLayerSchema( + collection: ContentConfig['collections'][T], collectionKey: T, -): Promise { - if (collection?.schema) { - return `InferEntrySchema<${collectionKey}>`; +): Promise { + const cached = schemaCache.get(collectionKey); + if (cached) { + return cached; } if ( @@ -375,6 +378,23 @@ async function typeForCollection( if (typeof schema === 'function') { schema = await schema(); } + if (schema) { + schemaCache.set(collectionKey, await schema); + return schema; + } + } +} + +async function typeForCollection( + collection: ContentConfig['collections'][T] | undefined, + collectionKey: T, +): Promise { + if (collection?.schema) { + return `InferEntrySchema<${collectionKey}>`; + } + + if (collection?.type === CONTENT_LAYER_TYPE) { + const schema = await getContentLayerSchema(collection, collectionKey); if (schema) { const ast = zodToTs(schema); return printNode(ast.node); @@ -418,6 +438,8 @@ async function writeContentFiles({ entries: {}, }; } + + let contentCollectionsMap: CollectionEntryMap = {}; for (const collectionKey of Object.keys(collectionEntryMap).sort()) { const collectionConfig = contentConfig?.collections[JSON.parse(collectionKey)]; const collection = collectionEntryMap[collectionKey]; @@ -451,7 +473,7 @@ async function writeContentFiles({ collection.type === 'unknown' ? // Add empty / unknown collections to the data type map by default // This ensures `getCollection('empty-collection')` doesn't raise a type error - collectionConfig?.type ?? 'data' + (collectionConfig?.type ?? 'data') : collection.type; const collectionEntryKeys = Object.keys(collection.entries).sort(); @@ -489,40 +511,60 @@ async function writeContentFiles({ } if (collectionConfig?.schema) { - let zodSchemaForJson = - typeof collectionConfig.schema === 'function' - ? collectionConfig.schema({ image: () => z.string() }) - : collectionConfig.schema; - if (zodSchemaForJson instanceof z.ZodObject) { - zodSchemaForJson = zodSchemaForJson.extend({ - $schema: z.string().optional(), - }); - } - try { - await fs.promises.writeFile( - new URL(`./${collectionKey.replace(/"/g, '')}.schema.json`, collectionSchemasDir), - JSON.stringify( - zodToJsonSchema(zodSchemaForJson, { - name: collectionKey.replace(/"/g, ''), - markdownDescription: true, - errorMessages: true, - // Fix for https://github.com/StefanTerdell/zod-to-json-schema/issues/110 - dateStrategy: ['format:date-time', 'format:date', 'integer'], - }), - null, - 2, - ), - ); - } catch (err) { - // This should error gracefully and not crash the dev server - logger.warn( - 'content', - `An error was encountered while creating the JSON schema for the ${collectionKey} collection. Proceeding without it. Error: ${err}`, - ); - } + await generateJSONSchema( + fs, + collectionConfig, + collectionKey, + collectionSchemasDir, + logger, + ); } break; } + + if ( + settings.config.experimental.contentIntellisense && + collectionConfig && + (collectionConfig.schema || (await getContentLayerSchema(collectionConfig, collectionKey))) + ) { + await generateJSONSchema(fs, collectionConfig, collectionKey, collectionSchemasDir, logger); + + contentCollectionsMap[collectionKey] = collection; + } + } + + if (settings.config.experimental.contentIntellisense) { + let contentCollectionManifest: { + collections: { hasSchema: boolean; name: string }[]; + entries: Record; + } = { + collections: [], + entries: {}, + }; + Object.entries(contentCollectionsMap).forEach(([collectionKey, collection]) => { + const collectionConfig = contentConfig?.collections[JSON.parse(collectionKey)]; + const key = JSON.parse(collectionKey); + + contentCollectionManifest.collections.push({ + hasSchema: Boolean(collectionConfig?.schema || schemaCache.has(collectionKey)), + name: key, + }); + + Object.keys(collection.entries).forEach((entryKey) => { + const entryPath = new URL( + JSON.parse(entryKey), + contentPaths.contentDir + `${key}/`, + ).toString(); + + // Save entry path in lower case to avoid case sensitivity issues between Windows and Unix + contentCollectionManifest.entries[entryPath.toLowerCase()] = key; + }); + }); + + await fs.promises.writeFile( + new URL('./collections.json', collectionSchemasDir), + JSON.stringify(contentCollectionManifest, null, 2), + ); } if (!fs.existsSync(settings.dotAstroDir)) { @@ -560,3 +602,48 @@ async function writeContentFiles({ }); } } + +async function generateJSONSchema( + fsMod: typeof import('node:fs'), + collectionConfig: CollectionConfig, + collectionKey: string, + collectionSchemasDir: URL, + logger: Logger, +) { + let zodSchemaForJson = + typeof collectionConfig.schema === 'function' + ? collectionConfig.schema({ image: () => z.string() }) + : collectionConfig.schema; + + if (!zodSchemaForJson && collectionConfig.type === CONTENT_LAYER_TYPE) { + zodSchemaForJson = await getContentLayerSchema(collectionConfig, collectionKey); + } + + if (zodSchemaForJson instanceof z.ZodObject) { + zodSchemaForJson = zodSchemaForJson.extend({ + $schema: z.string().optional(), + }); + } + try { + await fsMod.promises.writeFile( + new URL(`./${collectionKey.replace(/"/g, '')}.schema.json`, collectionSchemasDir), + JSON.stringify( + zodToJsonSchema(zodSchemaForJson, { + name: collectionKey.replace(/"/g, ''), + markdownDescription: true, + errorMessages: true, + // Fix for https://github.com/StefanTerdell/zod-to-json-schema/issues/110 + dateStrategy: ['format:date-time', 'format:date', 'integer'], + }), + null, + 2, + ), + ); + } catch (err) { + // This should error gracefully and not crash the dev server + logger.warn( + 'content', + `An error was encountered while creating the JSON schema for the ${collectionKey} collection. Proceeding without it. Error: ${err}`, + ); + } +} diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index b72c8c31709f..f49b4708e714 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -89,6 +89,7 @@ export const ASTRO_CONFIG_DEFAULTS = { clientPrerender: false, globalRoutePriority: false, serverIslands: false, + contentIntellisense: false, env: { validateSecrets: false, }, @@ -539,6 +540,10 @@ export const AstroConfigSchema = z.object({ .boolean() .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.serverIslands), + contentIntellisense: z + .boolean() + .optional() + .default(ASTRO_CONFIG_DEFAULTS.experimental.contentIntellisense), contentLayer: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.contentLayer), }) .strict( diff --git a/packages/astro/test/content-intellisense.test.js b/packages/astro/test/content-intellisense.test.js new file mode 100644 index 000000000000..dc93919999a1 --- /dev/null +++ b/packages/astro/test/content-intellisense.test.js @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; + +describe('Content Intellisense', () => { + /** @type {import("./test-utils.js").Fixture} */ + let fixture; + + /** @type {string[]} */ + let collectionsDir = []; + + /** @type {{collections: {hasSchema: boolean, name: string}[], entries: Record}} */ + let manifest = undefined; + + before(async () => { + fixture = await loadFixture({ root: './fixtures/content-intellisense/' }); + await fixture.build(); + + collectionsDir = await fixture.readdir('../.astro/collections'); + manifest = JSON.parse(await fixture.readFile('../.astro/collections/collections.json')); + }); + + it('generate JSON schemas for content collections', async () => { + assert.deepEqual(collectionsDir.includes('blog-cc.schema.json'), true); + }); + + it('generate JSON schemas for content layer', async () => { + assert.deepEqual(collectionsDir.includes('blog-cl.schema.json'), true); + }); + + it('manifest exists', async () => { + assert.notEqual(manifest, undefined); + }); + + it('manifest has content collections', async () => { + const manifestCollections = manifest.collections.map((collection) => collection.name); + assert.equal( + manifestCollections.includes('blog-cc'), + true, + "Expected 'blog-cc' collection in manifest", + ); + }); + + it('manifest has content layer', async () => { + const manifestCollections = manifest.collections.map((collection) => collection.name); + assert.equal( + manifestCollections.includes('blog-cl'), + true, + "Expected 'blog-cl' collection in manifest", + ); + }); + + it('has entries for content collections', async () => { + const collectionEntries = Object.entries(manifest.entries).filter((entry) => + entry[0].includes( + '/astro/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/', + ), + ); + assert.equal(collectionEntries.length, 3, "Expected 3 entries for 'blog-cc' collection"); + assert.equal( + collectionEntries.every((entry) => entry[1] === 'blog-cc'), + true, + "Expected 3 entries for 'blog-cc' collection to have 'blog-cc' as collection", + ); + }); + + it('has entries for content layer', async () => { + const collectionEntries = Object.entries(manifest.entries).filter((entry) => + entry[0].includes('/astro/packages/astro/test/fixtures/content-intellisense/src/blog-cl/'), + ); + + assert.equal(collectionEntries.length, 3, "Expected 3 entries for 'blog-cl' collection"); + assert.equal( + collectionEntries.every((entry) => entry[1] === 'blog-cl'), + true, + "Expected 3 entries for 'blog-cl' collection to have 'blog-cl' as collection name", + ); + }); +}); diff --git a/packages/astro/test/fixtures/content-collections/astro.config.mjs b/packages/astro/test/fixtures/content-collections/astro.config.mjs index aa89463ab72b..911cb3a99881 100644 --- a/packages/astro/test/fixtures/content-collections/astro.config.mjs +++ b/packages/astro/test/fixtures/content-collections/astro.config.mjs @@ -4,4 +4,7 @@ import { defineConfig } from 'astro/config'; // https://astro.build/config export default defineConfig({ integrations: [mdx()], + experimental: { + contentIntellisense: true + } }); diff --git a/packages/astro/test/fixtures/content-intellisense/astro.config.mjs b/packages/astro/test/fixtures/content-intellisense/astro.config.mjs new file mode 100644 index 000000000000..f6358a896fab --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/astro.config.mjs @@ -0,0 +1,12 @@ +import markdoc from "@astrojs/markdoc"; +import mdx from '@astrojs/mdx'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [mdx(), markdoc()], + experimental: { + contentLayer: true, + contentIntellisense: true + } +}); diff --git a/packages/astro/test/fixtures/content-intellisense/package.json b/packages/astro/test/fixtures/content-intellisense/package.json new file mode 100644 index 000000000000..1e22bf9946f6 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/content-intellisense", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/mdx": "workspace:*", + "@astrojs/markdoc": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry.md b/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry.md new file mode 100644 index 000000000000..caaa4ebeff82 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry.md @@ -0,0 +1,3 @@ +--- +title: "Markdown" +--- diff --git a/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry2.mdx b/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry2.mdx new file mode 100644 index 000000000000..0872819c8cf1 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry2.mdx @@ -0,0 +1,3 @@ +--- +title: "MDX" +--- diff --git a/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry3.mdoc b/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry3.mdoc new file mode 100644 index 000000000000..e13eaa2f6ac8 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry3.mdoc @@ -0,0 +1,3 @@ +--- +title: "Markdoc" +--- diff --git a/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry.md b/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry.md new file mode 100644 index 000000000000..caaa4ebeff82 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry.md @@ -0,0 +1,3 @@ +--- +title: "Markdown" +--- diff --git a/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry2.mdx b/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry2.mdx new file mode 100644 index 000000000000..0872819c8cf1 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry2.mdx @@ -0,0 +1,3 @@ +--- +title: "MDX" +--- diff --git a/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry3.mdoc b/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry3.mdoc new file mode 100644 index 000000000000..e13eaa2f6ac8 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry3.mdoc @@ -0,0 +1,3 @@ +--- +title: "Markdoc" +--- diff --git a/packages/astro/test/fixtures/content-intellisense/src/content/config.ts b/packages/astro/test/fixtures/content-intellisense/src/content/config.ts new file mode 100644 index 000000000000..64120adabe20 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/content/config.ts @@ -0,0 +1,24 @@ +import { glob } from 'astro/loaders'; +import { defineCollection, z } from 'astro:content'; + +const blogCC = defineCollection({ + type: 'content', + schema: z.object({ + title: z.string(), + description: z.string().optional(), + }), +}); + +const blogCL = defineCollection({ + // By default the ID is a slug, generated from the path of the file relative to `base` + loader: glob({ pattern: "**/*", base: "./src/blog-cl" }), + schema: z.object({ + title: z.string(), + description: z.string().optional(), + }), +}); + +export const collections = { + "blog-cc": blogCC, + "blog-cl": blogCL, +}; diff --git a/packages/astro/test/fixtures/content-intellisense/src/utils.js b/packages/astro/test/fixtures/content-intellisense/src/utils.js new file mode 100644 index 000000000000..3a6244327862 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/utils.js @@ -0,0 +1,8 @@ +export function stripRenderFn(entryWithRender) { + const { render, ...entry } = entryWithRender; + return entry; +} + +export function stripAllRenderFn(collection = []) { + return collection.map(stripRenderFn); +} diff --git a/packages/astro/test/fixtures/content-layer/astro.config.mjs b/packages/astro/test/fixtures/content-layer/astro.config.mjs index c3fd1366a07a..3266e5e8c0ad 100644 --- a/packages/astro/test/fixtures/content-layer/astro.config.mjs +++ b/packages/astro/test/fixtures/content-layer/astro.config.mjs @@ -13,5 +13,6 @@ export default defineConfig({ }, experimental: { contentLayer: true, + contentIntellisense: true, }, }); diff --git a/packages/astro/test/fixtures/content-layer/src/content/config.ts b/packages/astro/test/fixtures/content-layer/src/content/config.ts index 452ba6631ac2..1689a56b1c28 100644 --- a/packages/astro/test/fixtures/content-layer/src/content/config.ts +++ b/packages/astro/test/fixtures/content-layer/src/content/config.ts @@ -99,11 +99,13 @@ const increment = defineCollection({ }, }); }, + // Example of a loader that returns an async schema function + schema: async () => z.object({ + lastValue: z.number(), + lastUpdated: z.date(), + }), }, - schema: z.object({ - lastValue: z.number(), - lastUpdated: z.date(), - }), + }); export const collections = { blog, dogs, cats, numbers, spacecraft, increment }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b33a8d363cd6..764eb430ea89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2716,6 +2716,18 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/content-intellisense: + dependencies: + '@astrojs/markdoc': + specifier: workspace:* + version: link:../../../../integrations/markdoc + '@astrojs/mdx': + specifier: workspace:* + version: link:../../../../integrations/mdx + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/content-layer: dependencies: '@astrojs/mdx':