feat(images): Move image() to schema so we can do relative images easily instead of clumsily

This commit is contained in:
Princesseuh 2023-03-29 17:15:53 +02:00
parent f0b732d326
commit 6b0abd6aea
No known key found for this signature in database
GPG key ID: 105BBD6D57F2B0C0
11 changed files with 63 additions and 147 deletions

View file

@ -3,19 +3,23 @@ import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import slash from 'slash'; import slash from 'slash';
import type { AstroConfig, AstroSettings } from '../../@types/astro'; import type { AstroConfig, AstroSettings } from '../../@types/astro';
import { imageMetadata } from './metadata.js'; import { imageMetadata, type Metadata } from './metadata.js';
export async function emitESMImage( export async function emitESMImage(
id: string, id: string | undefined,
watchMode: boolean, watchMode: boolean,
fileEmitter: any, fileEmitter: any,
settings: Pick<AstroSettings, 'config'> settings: Pick<AstroSettings, 'config'>
) { ): Promise<Metadata | undefined> {
if (!id) {
return undefined;
}
const url = pathToFileURL(id); const url = pathToFileURL(id);
const meta = await imageMetadata(url); const meta = await imageMetadata(url);
if (!meta) { if (!meta) {
return; return undefined;
} }
// Build // Build
@ -48,13 +52,13 @@ export async function emitESMImage(
* due to Vite dependencies in core. * due to Vite dependencies in core.
*/ */
function rootRelativePath(config: Pick<AstroConfig, 'root'>, url: URL) { function rootRelativePath(config: Pick<AstroConfig, 'root'>, url: URL): string {
const basePath = fileURLToNormalizedPath(url); const basePath = fileURLToNormalizedPath(url);
const rootPath = fileURLToNormalizedPath(config.root); const rootPath = fileURLToNormalizedPath(config.root);
return prependForwardSlash(basePath.slice(rootPath.length)); return prependForwardSlash(basePath.slice(rootPath.length));
} }
function prependForwardSlash(filePath: string) { function prependForwardSlash(filePath: string): string {
return filePath[0] === '/' ? filePath : '/' + filePath; return filePath[0] === '/' ? filePath : '/' + filePath;
} }
@ -64,6 +68,6 @@ function fileURLToNormalizedPath(filePath: URL): string {
return slash(fileURLToPath(filePath) + filePath.search).replace(/\\/g, '/'); return slash(fileURLToPath(filePath) + filePath.search).replace(/\\/g, '/');
} }
export function emoji(char: string, fallback: string) { export function emoji(char: string, fallback: string): string {
return process.platform !== 'win32' ? char : fallback; return process.platform !== 'win32' ? char : fallback;
} }

View file

@ -1,20 +1,28 @@
import { pathToFileURL } from 'url'; import type { PluginContext } from 'rollup';
import { z } from 'zod'; import { z } from 'zod';
import { import type { AstroSettings } from '../@types/astro.js';
imageMetadata as internalGetImageMetadata, import { emitESMImage } from '../assets/index.js';
type Metadata,
} from '../assets/utils/metadata.js';
export function createImage(options: { assetsDir: string; relAssetsDir: string }) { export function createImage(
return () => { settings: AstroSettings,
if (options.assetsDir === 'undefined') { pluginContext: PluginContext,
entryFilePath: string
) {
if (!settings.config.experimental.assets) {
throw new Error('Enable `experimental.assets` in your Astro config to use image()'); throw new Error('Enable `experimental.assets` in your Astro config to use image()');
} }
return z.string({ description: '__image' }).transform(async (imagePath, ctx) => { return () => {
const imageMetadata = await getImageMetadata(pathToFileURL(imagePath)); return z.string().transform(async (imagePath, ctx) => {
const resolvedFilePath = (await pluginContext.resolve(imagePath, entryFilePath))?.id;
const metadata = await emitESMImage(
resolvedFilePath,
pluginContext.meta.watchMode,
pluginContext.emitFile,
settings
);
if (!imageMetadata) { if (!metadata) {
ctx.addIssue({ ctx.addIssue({
code: 'custom', code: 'custom',
message: `Image ${imagePath} does not exist. Is the path correct?`, message: `Image ${imagePath} does not exist. Is the path correct?`,
@ -24,20 +32,7 @@ export function createImage(options: { assetsDir: string; relAssetsDir: string }
return z.NEVER; return z.NEVER;
} }
return imageMetadata; return metadata;
}); });
}; };
} }
async function getImageMetadata(
imagePath: URL
): Promise<(Metadata & { __astro_asset: true }) | undefined> {
const meta = await internalGetImageMetadata(imagePath);
if (!meta) {
return undefined;
}
delete meta.orientation;
return { ...meta, __astro_asset: true };
}

View file

@ -14,7 +14,7 @@ declare module 'astro:content' {
(typeof entryMap)[C][keyof (typeof entryMap)[C]]; (typeof entryMap)[C][keyof (typeof entryMap)[C]];
// This needs to be in sync with ImageMetadata // This needs to be in sync with ImageMetadata
export const image: () => import('astro/zod').ZodObject<{ type ImageFunction = () => import('astro/zod').ZodObject<{
src: import('astro/zod').ZodString; src: import('astro/zod').ZodString;
width: import('astro/zod').ZodNumber; width: import('astro/zod').ZodNumber;
height: import('astro/zod').ZodNumber; height: import('astro/zod').ZodNumber;
@ -45,7 +45,7 @@ declare module 'astro:content' {
| import('astro/zod').ZodEffects<BaseSchemaWithoutEffects>; | import('astro/zod').ZodEffects<BaseSchemaWithoutEffects>;
type BaseCollectionConfig<S extends BaseSchema> = { type BaseCollectionConfig<S extends BaseSchema> = {
schema?: S; schema?: S | (({ image }: { image: ImageFunction }) => S);
slug?: (entry: { slug?: (entry: {
id: CollectionEntry<keyof typeof entryMap>['id']; id: CollectionEntry<keyof typeof entryMap>['id'];
defaultSlug: string; defaultSlug: string;

View file

@ -1,7 +0,0 @@
import { createImage } from 'astro/content/runtime-assets';
const assetsDir = '@@ASSETS_DIR@@';
export const image = createImage({
assetsDir,
});

View file

@ -3,14 +3,14 @@ import matter from 'gray-matter';
import fsMod from 'node:fs'; import fsMod from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import type { EmitFile, PluginContext } from 'rollup'; import type { PluginContext } from 'rollup';
import { normalizePath, type ErrorPayload as ViteErrorPayload, type ViteDevServer } from 'vite'; import { normalizePath, type ViteDevServer, type ErrorPayload as ViteErrorPayload } from 'vite';
import { z } from 'zod'; import { z } from 'zod';
import type { AstroConfig, AstroSettings } from '../@types/astro.js'; import type { AstroConfig, AstroSettings } from '../@types/astro.js';
import { emitESMImage } from '../assets/utils/emitAsset.js';
import { AstroError, AstroErrorData } from '../core/errors/index.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js';
import { CONTENT_TYPES_FILE } from './consts.js'; import { CONTENT_TYPES_FILE } from './consts.js';
import { errorMap } from './error-map.js'; import { errorMap } from './error-map.js';
import { createImage } from './runtime-assets.js';
export const collectionConfigParser = z.object({ export const collectionConfigParser = z.object({
schema: z.any().optional(), schema: z.any().optional(),
@ -33,7 +33,6 @@ export type CollectionConfig = z.infer<typeof collectionConfigParser>;
export type ContentConfig = z.infer<typeof contentConfigParser>; export type ContentConfig = z.infer<typeof contentConfigParser>;
type EntryInternal = { rawData: string | undefined; filePath: string }; type EntryInternal = { rawData: string | undefined; filePath: string };
export type EntryInfo = { export type EntryInfo = {
id: string; id: string;
slug: string; slug: string;
@ -45,31 +44,6 @@ export const msg = {
`${collection} does not have a config. We suggest adding one for type safety!`, `${collection} does not have a config. We suggest adding one for type safety!`,
}; };
/**
* Mutate (arf) the entryData to reroute assets to their final paths
*/
export async function patchAssets(
frontmatterEntry: Record<string, any>,
watchMode: boolean,
fileEmitter: EmitFile,
astroSettings: AstroSettings
) {
for (const key of Object.keys(frontmatterEntry)) {
if (typeof frontmatterEntry[key] === 'object' && frontmatterEntry[key] !== null) {
if (frontmatterEntry[key]['__astro_asset']) {
frontmatterEntry[key] = await emitESMImage(
frontmatterEntry[key].src,
watchMode,
fileEmitter,
astroSettings
);
} else {
await patchAssets(frontmatterEntry[key], watchMode, fileEmitter, astroSettings);
}
}
}
}
export function getEntrySlug({ export function getEntrySlug({
id, id,
collection, collection,
@ -89,71 +63,31 @@ export function getEntrySlug({
export async function getEntryData( export async function getEntryData(
entry: EntryInfo & { unvalidatedData: Record<string, unknown>; _internal: EntryInternal }, entry: EntryInfo & { unvalidatedData: Record<string, unknown>; _internal: EntryInternal },
collectionConfig: CollectionConfig, collectionConfig: CollectionConfig,
resolver: (idToResolve: string) => ReturnType<PluginContext['resolve']> pluginContext: PluginContext,
settings: AstroSettings
) { ) {
// Remove reserved `slug` field before parsing data // Remove reserved `slug` field before parsing data
let { slug, ...data } = entry.unvalidatedData; let { slug, ...data } = entry.unvalidatedData;
if (collectionConfig.schema) {
// TODO: remove for 2.0 stable release const schema =
if ( typeof collectionConfig.schema === 'function'
typeof collectionConfig.schema === 'object' && ? collectionConfig.schema({
!('safeParseAsync' in collectionConfig.schema) image: createImage(settings, pluginContext, entry._internal.filePath),
) { })
throw new AstroError({ : collectionConfig.schema;
title: 'Invalid content collection config',
message: `New: Content collection schemas must be Zod objects. Update your collection config to use \`schema: z.object({...})\` instead of \`schema: {...}\`.`, if (schema) {
hint: 'See https://docs.astro.build/en/reference/api-reference/#definecollection for an example.',
code: 99999,
});
}
// Catch reserved `slug` field inside schema // Catch reserved `slug` field inside schema
// Note: will not warn for `z.union` or `z.intersection` schemas // Note: will not warn for `z.union` or `z.intersection` schemas
if ( if (typeof schema === 'object' && 'shape' in schema && schema.shape.slug) {
typeof collectionConfig.schema === 'object' &&
'shape' in collectionConfig.schema &&
collectionConfig.schema.shape.slug
) {
throw new AstroError({ throw new AstroError({
...AstroErrorData.ContentSchemaContainsSlugError, ...AstroErrorData.ContentSchemaContainsSlugError,
message: AstroErrorData.ContentSchemaContainsSlugError.message(entry.collection), message: AstroErrorData.ContentSchemaContainsSlugError.message(entry.collection),
}); });
} }
/**
* Resolve all the images referred to in the frontmatter from the file requesting them
*/
async function preprocessAssetPaths(object: Record<string, any>) {
if (typeof object !== 'object' || object === null) return;
for (let [schemaName, schema] of Object.entries<any>(object)) {
if (schema._def.description === '__image') {
object[schemaName] = z.preprocess(
async (value: unknown) => {
if (!value || typeof value !== 'string') return value;
return (
(await resolver(value))?.id ??
path.join(path.dirname(entry._internal.filePath), value)
);
},
schema,
{ description: '__image' }
);
} else if ('shape' in schema) {
await preprocessAssetPaths(schema.shape);
} else if ('unwrap' in schema) {
const unwrapped = schema.unwrap().shape;
if (unwrapped) {
await preprocessAssetPaths(unwrapped);
}
}
}
}
await preprocessAssetPaths(collectionConfig.schema.shape);
// Use `safeParseAsync` to allow async transforms // Use `safeParseAsync` to allow async transforms
const parsed = await collectionConfig.schema.safeParseAsync(entry.unvalidatedData, { const parsed = await schema.safeParseAsync(entry.unvalidatedData, {
errorMap, errorMap,
}); });
if (parsed.success) { if (parsed.success) {

View file

@ -10,6 +10,7 @@ import { AstroError } from '../core/errors/errors.js';
import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index.js'; import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index.js';
import { CONTENT_FLAG } from './consts.js'; import { CONTENT_FLAG } from './consts.js';
import { import {
NoCollectionError,
getContentEntryExts, getContentEntryExts,
getContentPaths, getContentPaths,
getEntryData, getEntryData,
@ -17,8 +18,6 @@ import {
getEntrySlug, getEntrySlug,
getEntryType, getEntryType,
globalContentConfigObserver, globalContentConfigObserver,
NoCollectionError,
patchAssets,
type ContentConfig, type ContentConfig,
} from './utils.js'; } from './utils.js';
@ -235,11 +234,11 @@ export const _internal = {
? await getEntryData( ? await getEntryData(
{ id, collection, slug, _internal, unvalidatedData }, { id, collection, slug, _internal, unvalidatedData },
collectionConfig, collectionConfig,
(idToResolve: string) => pluginContext.resolve(idToResolve, fileId) pluginContext,
settings
) )
: unvalidatedData; : unvalidatedData;
await patchAssets(data, pluginContext.meta.watchMode, pluginContext.emitFile, settings);
const contentEntryModule: ContentEntryModule = { const contentEntryModule: ContentEntryModule = {
id, id,
slug, slug,

View file

@ -24,9 +24,6 @@ export function astroContentVirtualModPlugin({
); );
const contentEntryExts = getContentEntryExts(settings); const contentEntryExts = getContentEntryExts(settings);
const assetsDir = settings.config.experimental.assets
? contentPaths.assetsDir.toString()
: 'undefined';
const extGlob = const extGlob =
contentEntryExts.length === 1 contentEntryExts.length === 1
? // Wrapping {...} breaks when there is only one extension ? // Wrapping {...} breaks when there is only one extension
@ -38,14 +35,8 @@ export function astroContentVirtualModPlugin({
.replace('@@CONTENT_DIR@@', relContentDir) .replace('@@CONTENT_DIR@@', relContentDir)
.replace('@@ENTRY_GLOB_PATH@@', entryGlob) .replace('@@ENTRY_GLOB_PATH@@', entryGlob)
.replace('@@RENDER_ENTRY_GLOB_PATH@@', entryGlob); .replace('@@RENDER_ENTRY_GLOB_PATH@@', entryGlob);
const virtualAssetsModContents = fsMod
.readFileSync(contentPaths.virtualAssetsModTemplate, 'utf-8')
.replace('@@ASSETS_DIR@@', assetsDir);
const astroContentVirtualModuleId = '\0' + VIRTUAL_MODULE_ID; const astroContentVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
const allContents = settings.config.experimental.assets
? virtualModContents + virtualAssetsModContents
: virtualModContents;
return { return {
name: 'astro-content-virtual-mod-plugin', name: 'astro-content-virtual-mod-plugin',
@ -58,7 +49,7 @@ export function astroContentVirtualModPlugin({
load(id) { load(id) {
if (id === astroContentVirtualModuleId) { if (id === astroContentVirtualModuleId) {
return { return {
code: allContents, code: virtualModContents,
}; };
} }
}, },

View file

@ -1,7 +1,7 @@
import { defineCollection, image, z } from "astro:content"; import { defineCollection, z } from "astro:content";
const blogCollection = defineCollection({ const blogCollection = defineCollection({
schema: z.object({ schema: ({image}) => z.object({
title: z.string(), title: z.string(),
image: image(), image: image(),
cover: z.object({ cover: z.object({

View file

@ -1,7 +1,7 @@
import { defineCollection, image, z } from "astro:content"; import { defineCollection, z } from "astro:content";
const blogCollection = defineCollection({ const blogCollection = defineCollection({
schema: z.object({ schema: ({image}) => z.object({
title: z.string(), title: z.string(),
image: image(), image: image(),
cover: z.object({ cover: z.object({

View file

@ -1,7 +1,7 @@
import { defineCollection, image, z } from "astro:content"; import { defineCollection, z } from "astro:content";
const blogCollection = defineCollection({ const blogCollection = defineCollection({
schema: z.object({ schema: ({image}) => z.object({
title: z.string(), title: z.string(),
image: image(), image: image(),
cover: z.object({ cover: z.object({

View file

@ -1,7 +1,7 @@
import { defineCollection, image, z } from "astro:content"; import { defineCollection, z } from "astro:content";
const blogCollection = defineCollection({ const blogCollection = defineCollection({
schema: z.object({ schema: ({image}) => z.object({
title: z.string(), title: z.string(),
image: image(), image: image(),
cover: z.object({ cover: z.object({