Skip to content

Commit

Permalink
Add file generation and flag for content intellisense (#11639)
Browse files Browse the repository at this point in the history
* feat: add type to infer input type of collection

* refactor:

* feat: generate json schema for content too

* feat: generate a manifest of all the collections

* refactor: unnecessary type

* fix: only add content collections to manifest

* chore: changeset

* fix: generate file URLs

* fix: flag it properly

* fix: save in lower case

* docs: add jsdoc to experimental option

* nit: move function out

* fix: match vscode flag name

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <[email protected]>

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <[email protected]>

* Update serious-pumas-run.md

* test: add tests

* Add content layer support

* Apply suggestions from code review

* fix: test

* Update .changeset/serious-pumas-run.md

Co-authored-by: Sarah Rainsberger <[email protected]>

* Apply suggestions from code review

* Remove check for json

---------

Co-authored-by: Matt Kane <[email protected]>
Co-authored-by: Sarah Rainsberger <[email protected]>
  • Loading branch information
3 people authored Aug 14, 2024
1 parent e40043f commit bad2580
Show file tree
Hide file tree
Showing 21 changed files with 395 additions and 53 deletions.
21 changes: 21 additions & 0 deletions .changeset/serious-pumas-run.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 27 additions & 3 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { OutgoingHttpHeaders } from 'node:http';
import type { AddressInfo } from 'node:net';
import type {
MarkdownHeading,
MarkdownVFile,
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions packages/astro/src/content/content-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}

Expand Down
8 changes: 4 additions & 4 deletions packages/astro/src/content/data-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,16 @@ export class DataStore {
this.#collections = new Map();
}

get<T = unknown>(collectionName: string, key: string): T | undefined {
get<T = DataEntry>(collectionName: string, key: string): T | undefined {
return this.#collections.get(collectionName)?.get(String(key));
}

entries<T = unknown>(collectionName: string): Array<[id: string, T]> {
entries<T = DataEntry>(collectionName: string): Array<[id: string, T]> {
const collection = this.#collections.get(collectionName) ?? new Map();
return [...collection.entries()];
}

values<T = unknown>(collectionName: string): Array<T> {
values<T = DataEntry>(collectionName: string): Array<T> {
const collection = this.#collections.get(collectionName) ?? new Map();
return [...collection.values()];
}
Expand Down Expand Up @@ -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 {
Expand Down
171 changes: 129 additions & 42 deletions packages/astro/src/content/types-generator.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
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';
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,
Expand Down Expand Up @@ -358,12 +358,15 @@ function normalizeConfigPath(from: string, to: string) {
return `"${isRelativePath(configPath) ? '' : './'}${normalizedPath}"` as const;
}

async function typeForCollection<T extends keyof ContentConfig['collections']>(
collection: ContentConfig['collections'][T] | undefined,
const schemaCache = new Map<string, ZodSchema>();

async function getContentLayerSchema<T extends keyof ContentConfig['collections']>(
collection: ContentConfig['collections'][T],
collectionKey: T,
): Promise<string> {
if (collection?.schema) {
return `InferEntrySchema<${collectionKey}>`;
): Promise<ZodSchema | undefined> {
const cached = schemaCache.get(collectionKey);
if (cached) {
return cached;
}

if (
Expand All @@ -375,6 +378,23 @@ async function typeForCollection<T extends keyof ContentConfig['collections']>(
if (typeof schema === 'function') {
schema = await schema();
}
if (schema) {
schemaCache.set(collectionKey, await schema);
return schema;
}
}
}

async function typeForCollection<T extends keyof ContentConfig['collections']>(
collection: ContentConfig['collections'][T] | undefined,
collectionKey: T,
): Promise<string> {
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);
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<string, string>;
} = {
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)) {
Expand Down Expand Up @@ -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}`,
);
}
}
5 changes: 5 additions & 0 deletions packages/astro/src/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export const ASTRO_CONFIG_DEFAULTS = {
clientPrerender: false,
globalRoutePriority: false,
serverIslands: false,
contentIntellisense: false,
env: {
validateSecrets: false,
},
Expand Down Expand Up @@ -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(
Expand Down
Loading

0 comments on commit bad2580

Please sign in to comment.