-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
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
feat: image support for content layer #11469
Changes from all commits
26de2b5
49990d8
db17c89
9d0230a
d490f9d
eb46d50
49b18bb
52362ea
64aab18
e2f4030
8c36412
f76d37c
c5d8bfe
1cdc009
73f2a11
47d1a51
7ab726b
65920be
6ac785c
c87cc51
d80ec36
f45af30
5af09f7
0e8462a
49485d2
356d5f9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import { isRemotePath, removeBase } from '@astrojs/internal-helpers/path'; | ||
import { CONTENT_IMAGE_FLAG, IMAGE_IMPORT_PREFIX } from '../../content/consts.js'; | ||
import { shorthash } from '../../runtime/server/shorthash.js'; | ||
import { VALID_INPUT_FORMATS } from '../consts.js'; | ||
|
||
/** | ||
* Resolves an image src from a content file (such as markdown) to a module ID or import that can be resolved by Vite. | ||
* | ||
* @param imageSrc The src attribute of an image tag | ||
* @param filePath The path to the file that contains the imagem relative to the site root | ||
* @returns A module id of the image that can be rsolved by Vite, or undefined if it is not a local image | ||
*/ | ||
export function imageSrcToImportId(imageSrc: string, filePath: string): string | undefined { | ||
// If the import is coming from the data store it will have a special prefix to identify it | ||
// as an image import. We remove this prefix so that we can resolve the image correctly. | ||
imageSrc = removeBase(imageSrc, IMAGE_IMPORT_PREFIX); | ||
|
||
// We only care about local imports | ||
if (isRemotePath(imageSrc) || imageSrc.startsWith('/')) { | ||
return; | ||
} | ||
// We only care about images | ||
const ext = imageSrc.split('.').at(-1) as (typeof VALID_INPUT_FORMATS)[number] | undefined; | ||
if (!ext || !VALID_INPUT_FORMATS.includes(ext)) { | ||
return; | ||
} | ||
|
||
// The import paths are relative to the content (md) file, but when it's actually resolved it will | ||
// be in a single assets file, so relative paths will no longer work. To deal with this we use | ||
// a query parameter to store the original path to the file and append a query param flag. | ||
// This allows our Vite plugin to intercept the import and resolve the path relative to the | ||
// importer and get the correct full path for the imported image. | ||
|
||
const params = new URLSearchParams(CONTENT_IMAGE_FLAG); | ||
params.set('importer', filePath); | ||
return `${imageSrc}?${params.toString()}`; | ||
} | ||
|
||
export const importIdToSymbolName = (importId: string) => | ||
`__ASTRO_IMAGE_IMPORT_${shorthash(importId)}`; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,15 @@ | ||
import { promises as fs, type PathLike, existsSync } from 'fs'; | ||
import { imageSrcToImportId, importIdToSymbolName } from '../assets/utils/resolveImports.js'; | ||
import { AstroError, AstroErrorData } from '../core/errors/index.js'; | ||
|
||
const SAVE_DEBOUNCE_MS = 500; | ||
|
||
export interface RenderedContent { | ||
html: string; | ||
metadata?: Record<string, unknown>; | ||
metadata?: { | ||
imagePaths: Array<string>; | ||
[key: string]: unknown; | ||
}; | ||
} | ||
|
||
export interface DataEntry { | ||
|
@@ -21,9 +26,15 @@ export class DataStore { | |
|
||
#file?: PathLike; | ||
|
||
#assetsFile?: PathLike; | ||
|
||
#saveTimeout: NodeJS.Timeout | undefined; | ||
#assetsSaveTimeout: NodeJS.Timeout | undefined; | ||
|
||
#dirty = false; | ||
#assetsDirty = false; | ||
|
||
#assetImports = new Set<string>(); | ||
|
||
constructor() { | ||
this.#collections = new Map(); | ||
|
@@ -77,7 +88,77 @@ export class DataStore { | |
return this.#collections; | ||
} | ||
|
||
#saveToDiskDebounced = () => { | ||
addAssetImport(assetImport: string, filePath: string) { | ||
const id = imageSrcToImportId(assetImport, filePath); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a world where this is "asset kind"-agnostic? We sometimes get requests for supporting things like videos and general related assets There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I think it could, but I think we'd need to work out a way to separate them, as there's quite a bit of Astro Image-specific code in all of the asset handling stuff |
||
if (id) { | ||
this.#assetImports.add(id); | ||
// We debounce the writes to disk because addAssetImport is called for every image in every file, | ||
// and can be called many times in quick succession by a filesystem watcher. We only want to write | ||
// the file once, after all the imports have been added. | ||
this.#writeAssetsImportsDebounced(); | ||
} | ||
} | ||
|
||
addAssetImports(assets: Array<string>, filePath: string) { | ||
assets.forEach((asset) => this.addAssetImport(asset, filePath)); | ||
} | ||
|
||
async writeAssetImports(filePath: PathLike) { | ||
this.#assetsFile = filePath; | ||
|
||
if (this.#assetImports.size === 0) { | ||
try { | ||
await fs.writeFile(filePath, 'export default new Map();'); | ||
} catch (err) { | ||
throw new AstroError({ | ||
...(err as Error), | ||
...AstroErrorData.ContentLayerWriteError, | ||
}); | ||
} | ||
} | ||
|
||
if (!this.#assetsDirty && existsSync(filePath)) { | ||
return; | ||
} | ||
// Import the assets, with a symbol name that is unique to the import id. The import | ||
// for each asset is an object with path, format and dimensions. | ||
// We then export them all, mapped by the import id, so we can find them again in the build. | ||
const imports: Array<string> = []; | ||
const exports: Array<string> = []; | ||
this.#assetImports.forEach((id) => { | ||
const symbol = importIdToSymbolName(id); | ||
imports.push(`import ${symbol} from '${id}';`); | ||
exports.push(`[${JSON.stringify(id)}, ${symbol}]`); | ||
}); | ||
ematipico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const code = /* js */ ` | ||
${imports.join('\n')} | ||
export default new Map([${exports.join(', ')}]); | ||
`; | ||
try { | ||
await fs.writeFile(filePath, code); | ||
} catch (err) { | ||
throw new AstroError({ | ||
...(err as Error), | ||
...AstroErrorData.ContentLayerWriteError, | ||
}); | ||
} | ||
this.#assetsDirty = false; | ||
} | ||
|
||
#writeAssetsImportsDebounced() { | ||
ematipico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
this.#assetsDirty = true; | ||
ematipico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if (this.#assetsFile) { | ||
if (this.#assetsSaveTimeout) { | ||
clearTimeout(this.#assetsSaveTimeout); | ||
} | ||
this.#assetsSaveTimeout = setTimeout(() => { | ||
this.#assetsSaveTimeout = undefined; | ||
this.writeAssetImports(this.#assetsFile!); | ||
}, SAVE_DEBOUNCE_MS); | ||
} | ||
} | ||
|
||
#saveToDiskDebounced() { | ||
this.#dirty = true; | ||
// Only save to disk if it has already been saved once | ||
if (this.#file) { | ||
|
@@ -89,7 +170,7 @@ export class DataStore { | |
this.writeToDisk(this.#file!); | ||
}, SAVE_DEBOUNCE_MS); | ||
} | ||
}; | ||
} | ||
|
||
scopedStore(collectionName: string): ScopedDataStore { | ||
return { | ||
|
@@ -118,6 +199,9 @@ export class DataStore { | |
entry.body = body; | ||
} | ||
if (filePath) { | ||
if (filePath.startsWith('/')) { | ||
throw new Error(`File path must be relative to the site root. Got: ${filePath}`); | ||
} | ||
entry.filePath = filePath; | ||
} | ||
if (digest) { | ||
|
@@ -133,6 +217,10 @@ export class DataStore { | |
delete: (key: string) => this.delete(collectionName, key), | ||
clear: () => this.clear(collectionName), | ||
has: (key: string) => this.has(collectionName, key), | ||
addAssetImport: (assetImport: string, fileName: string) => | ||
this.addAssetImport(assetImport, fileName), | ||
addAssetImports: (assets: Array<string>, fileName: string) => | ||
this.addAssetImports(assets, fileName), | ||
}; | ||
} | ||
|
||
|
@@ -162,8 +250,11 @@ export class DataStore { | |
await fs.writeFile(filePath, this.toString()); | ||
this.#file = filePath; | ||
this.#dirty = false; | ||
} catch { | ||
throw new Error(`Failed to save data store to disk`); | ||
} catch (err) { | ||
throw new AstroError({ | ||
...(err as Error), | ||
...AstroErrorData.ContentLayerWriteError, | ||
}); | ||
} | ||
} | ||
|
||
|
@@ -203,6 +294,18 @@ export class DataStore { | |
export interface ScopedDataStore { | ||
get: (key: string) => DataEntry | undefined; | ||
entries: () => Array<[id: string, DataEntry]>; | ||
/** | ||
* Adds a new entry to the store. If an entry with the same ID already exists, | ||
* it will be replaced. | ||
* @param opts | ||
* @param opts.id The ID of the entry. Must be unique per collection. | ||
* @param opts.data The data to store. | ||
* @param opts.body The raw body of the content, if applicable. | ||
* @param opts.filePath The file path of the content, if applicable. Relative to the site root. | ||
* @param opts.digest A content digest, to check if the content has changed. | ||
* @param opts.rendered The rendered content, if applicable. | ||
* @returns | ||
*/ | ||
set: (opts: { | ||
id: string; | ||
data: Record<string, unknown>; | ||
|
@@ -216,6 +319,21 @@ export interface ScopedDataStore { | |
delete: (key: string) => void; | ||
clear: () => void; | ||
has: (key: string) => boolean; | ||
/** | ||
* Adds image etc assets to the store. These assets will be transformed | ||
* by Vite, and the URLs will be available in the final build. | ||
* @param assets An array of asset src values, relative to the importing file. | ||
* @param fileName The full path of the file that is importing the assets. | ||
*/ | ||
addAssetImports: (assets: Array<string>, fileName: string) => void; | ||
/** | ||
* Adds a single asset to the store. This asset will be transformed | ||
* by Vite, and the URL will be available in the final build. | ||
* @param assetImport | ||
* @param fileName | ||
* @returns | ||
*/ | ||
addAssetImport: (assetImport: string, fileName: string) => void; | ||
ematipico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
/** | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A you-know-who-free traverse lib