Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add file generation and flag for content intellisense #11639

Merged
merged 30 commits into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c103902
feat: add type to infer input type of collection
Princesseuh Jul 29, 2024
07e8c0e
refactor:
Princesseuh Aug 6, 2024
501a695
Merge branch 'main' into feat/content-collections-intellisense
Princesseuh Aug 6, 2024
591e337
feat: generate json schema for content too
Princesseuh Aug 6, 2024
bd05cd5
feat: generate a manifest of all the collections
Princesseuh Aug 7, 2024
1659ce2
refactor: unnecessary type
Princesseuh Aug 7, 2024
ae5467a
Merge branch 'main' into feat/content-collections-intellisense
Princesseuh Aug 8, 2024
188ce03
fix: only add content collections to manifest
Princesseuh Aug 8, 2024
fac3c34
Merge branch 'main' into feat/content-collections-intellisense
Princesseuh Aug 8, 2024
5044fc2
chore: changeset
Princesseuh Aug 8, 2024
d02f0b7
fix: generate file URLs
Princesseuh Aug 8, 2024
5205346
fix: flag it properly
Princesseuh Aug 8, 2024
7f98b61
fix: save in lower case
Princesseuh Aug 8, 2024
1390593
docs: add jsdoc to experimental option
Princesseuh Aug 9, 2024
8df9759
Merge branch 'content-layer' into feat/content-collections-intellisense
Princesseuh Aug 9, 2024
0d313f6
nit: move function out
Princesseuh Aug 9, 2024
d1f8cc8
Merge branch 'content-layer' into feat/content-collections-intellisense
ascorbic Aug 12, 2024
0461559
fix: match vscode flag name
Princesseuh Aug 12, 2024
3f25212
Update packages/astro/src/@types/astro.ts
Princesseuh Aug 12, 2024
a8ab210
Update packages/astro/src/@types/astro.ts
Princesseuh Aug 12, 2024
dabf08a
Update serious-pumas-run.md
Princesseuh Aug 12, 2024
0d2262f
test: add tests
Princesseuh Aug 12, 2024
f77959b
Add content layer support
ascorbic Aug 13, 2024
d9a26f7
Apply suggestions from code review
Princesseuh Aug 13, 2024
07eaff5
fix: test
Princesseuh Aug 13, 2024
a365604
Merge branch 'content-layer' into feat/content-collections-intellisense
Princesseuh Aug 13, 2024
c5a8530
Update .changeset/serious-pumas-run.md
Princesseuh Aug 13, 2024
e88ca5a
Apply suggestions from code review
Princesseuh Aug 13, 2024
917e159
Remove check for json
ascorbic Aug 14, 2024
4d87be8
Merge branch 'content-layer' into feat/content-collections-intellisense
Princesseuh Aug 14, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Princesseuh marked this conversation as resolved.
Show resolved Hide resolved

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.contentCollectionIntellisense
* @type {boolean}
Princesseuh marked this conversation as resolved.
Show resolved Hide resolved
* @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.
*/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there were a link to a discussion or RFC where you want people to leave feedback, or where there's more helpful information, we'd typically include it at the end here. If not, then no worries! We don't have this for some of our longer-standing experimental flags.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't expect it to be flagged for too long (it might get unflagged before 5.x), so I'd rather people report issues directly if there's bugs vs a discussion, I think

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 || entries[0].filePath.endsWith('.json')) {
ascorbic marked this conversation as resolved.
Show resolved Hide resolved
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 = {};
Princesseuh marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -551,3 +593,48 @@ async function writeContentFiles({
typeTemplateContent,
);
}

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
Loading