Skip to content

Commit 39bc3a5

Browse files
ascorbicematipico
andauthoredJun 12, 2024··
fix(astro): handle symlinked content collection directories (#11236)
* fix(astro): handle symlinked content collection directories * CHeck content dir exists and is a dir * Handle symlinks when generating chunk names * wip windows log * Use posix paths * Fix normalisation * :old-man-yells-at-windows-paths: * Update .changeset/fifty-clouds-clean.md Co-authored-by: Emanuele Stoppa <my.burning@gmail.com> * Changes from review * Add logging --------- Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
1 parent 2851b0a commit 39bc3a5

File tree

15 files changed

+174
-14
lines changed

15 files changed

+174
-14
lines changed
 

‎.changeset/fifty-clouds-clean.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes a case where symlinked content collection directories were not correctly resolved

‎packages/astro/src/content/utils.ts

+62-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { AstroError, AstroErrorData, MarkdownError, errorMap } from '../core/err
1616
import { isYAMLException } from '../core/errors/utils.js';
1717
import { CONTENT_FLAGS, PROPAGATED_ASSET_FLAG } from './consts.js';
1818
import { createImage } from './runtime-assets.js';
19-
19+
import type { Logger } from "../core/logger/core.js";
2020
/**
2121
* Amap from a collection + slug to the local file path.
2222
* This is used internally to resolve entry imports when using `getEntry()`.
@@ -167,6 +167,67 @@ export function getEntryConfigByExtMap<TEntryType extends ContentEntryType | Dat
167167
return map;
168168
}
169169

170+
export async function getSymlinkedContentCollections({
171+
contentDir,
172+
logger,
173+
fs
174+
}: {
175+
contentDir: URL;
176+
logger: Logger;
177+
fs: typeof fsMod;
178+
}): Promise<Map<string, string>> {
179+
const contentPaths = new Map<string, string>();
180+
const contentDirPath = fileURLToPath(contentDir);
181+
try {
182+
if (!fs.existsSync(contentDirPath) || !fs.lstatSync(contentDirPath).isDirectory()) {
183+
return contentPaths;
184+
}
185+
} catch {
186+
// Ignore if there isn't a valid content directory
187+
return contentPaths;
188+
}
189+
try {
190+
const contentDirEntries = await fs.promises.readdir(contentDir, { withFileTypes: true });
191+
for (const entry of contentDirEntries) {
192+
if (entry.isSymbolicLink()) {
193+
const entryPath = path.join(contentDirPath, entry.name);
194+
const realPath = await fs.promises.realpath(entryPath);
195+
contentPaths.set(normalizePath(realPath), entry.name);
196+
}
197+
}
198+
} catch (e) {
199+
logger.warn('content', `Error when reading content directory "${contentDir}"`);
200+
logger.debug('content', e);
201+
// If there's an error, return an empty map
202+
return new Map<string, string>();
203+
}
204+
205+
return contentPaths;
206+
}
207+
208+
export function reverseSymlink({
209+
entry,
210+
symlinks,
211+
contentDir,
212+
}: {
213+
entry: string | URL;
214+
contentDir: string | URL;
215+
symlinks?: Map<string, string>;
216+
}): string {
217+
const entryPath = normalizePath(typeof entry === 'string' ? entry : fileURLToPath(entry));
218+
const contentDirPath = typeof contentDir === 'string' ? contentDir : fileURLToPath(contentDir);
219+
if (!symlinks || symlinks.size === 0) {
220+
return entryPath;
221+
}
222+
223+
for (const [realPath, symlinkName] of symlinks) {
224+
if (entryPath.startsWith(realPath)) {
225+
return normalizePath(path.join(contentDirPath, symlinkName, entryPath.replace(realPath, '')));
226+
}
227+
}
228+
return entryPath;
229+
}
230+
170231
export function getEntryCollectionName({
171232
contentDir,
172233
entry,

‎packages/astro/src/content/vite-plugin-content-imports.ts

+18-3
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,14 @@ import {
2828
getEntryConfigByExtMap,
2929
getEntryData,
3030
getEntryType,
31+
getSymlinkedContentCollections,
3132
globalContentConfigObserver,
3233
hasContentFlag,
3334
parseEntrySlug,
3435
reloadContentConfigObserver,
36+
reverseSymlink,
3537
} from './utils.js';
38+
import type { Logger } from '../core/logger/core.js';
3639

3740
function getContentRendererByViteId(
3841
viteId: string,
@@ -63,9 +66,11 @@ const COLLECTION_TYPES_TO_INVALIDATE_ON = ['data', 'content', 'config'];
6366
export function astroContentImportPlugin({
6467
fs,
6568
settings,
69+
logger,
6670
}: {
6771
fs: typeof fsMod;
6872
settings: AstroSettings;
73+
logger: Logger;
6974
}): Plugin[] {
7075
const contentPaths = getContentPaths(settings.config, fs);
7176
const contentEntryExts = getContentEntryExts(settings);
@@ -75,16 +80,26 @@ export function astroContentImportPlugin({
7580
const dataEntryConfigByExt = getEntryConfigByExtMap(settings.dataEntryTypes);
7681
const { contentDir } = contentPaths;
7782
let shouldEmitFile = false;
78-
83+
let symlinks: Map<string, string>;
7984
const plugins: Plugin[] = [
8085
{
8186
name: 'astro:content-imports',
8287
config(_config, env) {
8388
shouldEmitFile = env.command === 'build';
8489
},
90+
async buildStart() {
91+
// Get symlinks once at build start
92+
symlinks = await getSymlinkedContentCollections({ contentDir, logger, fs });
93+
},
8594
async transform(_, viteId) {
8695
if (hasContentFlag(viteId, DATA_FLAG)) {
87-
const fileId = viteId.split('?')[0] ?? viteId;
96+
// By default, Vite will resolve symlinks to their targets. We need to reverse this for
97+
// content entries, so we can get the path relative to the content directory.
98+
const fileId = reverseSymlink({
99+
entry: viteId.split('?')[0] ?? viteId,
100+
contentDir,
101+
symlinks,
102+
});
88103
// Data collections don't need to rely on the module cache.
89104
// This cache only exists for the `render()` function specific to content.
90105
const { id, data, collection, _internal } = await getDataEntryModule({
@@ -109,7 +124,7 @@ export const _internal = {
109124
`;
110125
return code;
111126
} else if (hasContentFlag(viteId, CONTENT_FLAG)) {
112-
const fileId = viteId.split('?')[0];
127+
const fileId = reverseSymlink({ entry: viteId.split('?')[0], contentDir, symlinks });
113128
const { id, slug, collection, body, data, _internal } = await getContentEntryModule({
114129
fileId,
115130
entryConfigByExt: contentEntryConfigByExt,

‎packages/astro/src/core/build/static-build.ts

+18-6
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import { bgGreen, bgMagenta, black, green } from 'kleur/colors';
88
import * as vite from 'vite';
99
import type { RouteData } from '../../@types/astro.js';
1010
import { PROPAGATED_ASSET_FLAG } from '../../content/consts.js';
11-
import { hasAnyContentFlag } from '../../content/utils.js';
11+
import {
12+
getSymlinkedContentCollections,
13+
hasAnyContentFlag,
14+
reverseSymlink,
15+
} from '../../content/utils.js';
1216
import {
1317
type BuildInternals,
1418
createBuildInternals,
@@ -36,9 +40,10 @@ import { RESOLVED_SPLIT_MODULE_ID, RESOLVED_SSR_VIRTUAL_MODULE_ID } from './plug
3640
import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js';
3741
import type { StaticBuildOptions } from './types.js';
3842
import { encodeName, getTimeStat, viteBuildReturnToRollupOutputs } from './util.js';
43+
import type { Logger } from '../logger/core.js';
3944

4045
export async function viteBuild(opts: StaticBuildOptions) {
41-
const { allPages, settings } = opts;
46+
const { allPages, settings, logger } = opts;
4247
// Make sure we have an adapter before building
4348
if (isModeServerWithNoAdapter(opts.settings)) {
4449
throw new AstroError(AstroErrorData.NoAdapterInstalled);
@@ -78,7 +83,7 @@ export async function viteBuild(opts: StaticBuildOptions) {
7883
// Build your project (SSR application code, assets, client JS, etc.)
7984
const ssrTime = performance.now();
8085
opts.logger.info('build', `Building ${settings.config.output} entrypoints...`);
81-
const ssrOutput = await ssrBuild(opts, internals, pageInput, container);
86+
const ssrOutput = await ssrBuild(opts, internals, pageInput, container, logger);
8287
opts.logger.info('build', green(`✓ Completed in ${getTimeStat(ssrTime, performance.now())}.`));
8388

8489
settings.timer.end('SSR build');
@@ -166,7 +171,8 @@ async function ssrBuild(
166171
opts: StaticBuildOptions,
167172
internals: BuildInternals,
168173
input: Set<string>,
169-
container: AstroBuildPluginContainer
174+
container: AstroBuildPluginContainer,
175+
logger: Logger
170176
) {
171177
const buildID = Date.now().toString();
172178
const { allPages, settings, viteConfig } = opts;
@@ -175,7 +181,8 @@ async function ssrBuild(
175181
const routes = Object.values(allPages).flatMap((pageData) => pageData.route);
176182
const isContentCache = !ssr && settings.config.experimental.contentCollectionCache;
177183
const { lastVitePlugins, vitePlugins } = await container.runBeforeHook('server', input);
178-
184+
const contentDir = new URL('./src/content', settings.config.root);
185+
const symlinks = await getSymlinkedContentCollections({ contentDir, logger, fs });
179186
const viteBuildConfig: vite.InlineConfig = {
180187
...viteConfig,
181188
mode: viteConfig.mode || 'production',
@@ -251,7 +258,12 @@ async function ssrBuild(
251258
chunkInfo.facadeModuleId &&
252259
hasAnyContentFlag(chunkInfo.facadeModuleId)
253260
) {
254-
const [srcRelative, flag] = chunkInfo.facadeModuleId.split('/src/')[1].split('?');
261+
const moduleId = reverseSymlink({
262+
symlinks,
263+
entry: chunkInfo.facadeModuleId,
264+
contentDir,
265+
});
266+
const [srcRelative, flag] = moduleId.split('/src/')[1].split('?');
255267
if (flag === PROPAGATED_ASSET_FLAG) {
256268
return encodeName(`${removeFileExtension(srcRelative)}.entry.mjs`);
257269
}

‎packages/astro/src/core/create-vite.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export async function createVite(
148148
astroScannerPlugin({ settings, logger }),
149149
astroInjectEnvTsPlugin({ settings, logger, fs }),
150150
astroContentVirtualModPlugin({ fs, settings }),
151-
astroContentImportPlugin({ fs, settings }),
151+
astroContentImportPlugin({ fs, settings, logger }),
152152
astroContentAssetPropagationPlugin({ mode, settings }),
153153
vitePluginMiddleware({ settings }),
154154
vitePluginSSRManifest(),

‎packages/astro/test/content-collections.test.js

+22
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,28 @@ describe('Content Collections', () => {
9898
subject: 'My Newsletter',
9999
});
100100
});
101+
102+
it('Handles symlinked content', async () => {
103+
assert.ok(json.hasOwnProperty('withSymlinkedContent'));
104+
assert.equal(Array.isArray(json.withSymlinkedContent), true);
105+
106+
const ids = json.withSymlinkedContent.map((item) => item.id);
107+
assert.deepEqual(ids, ['first.md', 'second.md', 'third.md']);
108+
assert.equal(json.withSymlinkedContent[0].data.title, 'First Blog');
109+
});
110+
111+
it('Handles symlinked data', async () => {
112+
assert.ok(json.hasOwnProperty('withSymlinkedData'));
113+
assert.equal(Array.isArray(json.withSymlinkedData), true);
114+
115+
const ids = json.withSymlinkedData.map((item) => item.id);
116+
assert.deepEqual(ids, ['welcome']);
117+
assert.equal(
118+
json.withSymlinkedData[0].data.alt,
119+
'Futuristic landscape with chrome buildings and blue skies'
120+
);
121+
assert.notEqual(json.withSymlinkedData[0].data.src.src, undefined);
122+
});
101123
});
102124

103125
describe('Propagation', () => {
Loading

‎packages/astro/test/fixtures/content-collections/src/content/config.ts

+21-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const withSchemaConfig = defineCollection({
1111
isDraft: z.boolean().default(false),
1212
lang: z.enum(['en', 'fr', 'es']).default('en'),
1313
publishedAt: z.date().transform((val) => new Date(val)),
14-
})
14+
}),
1515
});
1616

1717
const withUnionSchema = defineCollection({
@@ -28,8 +28,27 @@ const withUnionSchema = defineCollection({
2828
]),
2929
});
3030

31+
const withSymlinkedData = defineCollection({
32+
type: 'data',
33+
schema: ({ image }) =>
34+
z.object({
35+
alt: z.string(),
36+
src: image(),
37+
}),
38+
});
39+
40+
const withSymlinkedContent = defineCollection({
41+
type: 'content',
42+
schema: z.object({
43+
title: z.string(),
44+
date: z.date(),
45+
}),
46+
});
47+
3148
export const collections = {
3249
'with-custom-slugs': withCustomSlugs,
3350
'with-schema-config': withSchemaConfig,
3451
'with-union-schema': withUnionSchema,
35-
}
52+
'with-symlinked-data': withSymlinkedData,
53+
'with-symlinked-content': withSymlinkedContent,
54+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../symlinked-collections/content-collection
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../symlinked-collections/data-collection

‎packages/astro/test/fixtures/content-collections/src/pages/collections.json.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ export async function GET() {
77
const withSchemaConfig = stripAllRenderFn(await getCollection('with-schema-config'));
88
const withSlugConfig = stripAllRenderFn(await getCollection('with-custom-slugs'));
99
const withUnionSchema = stripAllRenderFn(await getCollection('with-union-schema'));
10+
const withSymlinkedContent = stripAllRenderFn(await getCollection('with-symlinked-content'));
11+
const withSymlinkedData = stripAllRenderFn(await getCollection('with-symlinked-data'));
1012

1113
return new Response(
12-
devalue.stringify({ withoutConfig, withSchemaConfig, withSlugConfig, withUnionSchema })
14+
devalue.stringify({ withoutConfig, withSchemaConfig, withSlugConfig, withUnionSchema, withSymlinkedContent, withSymlinkedData }),
1315
);
1416
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
title: "First Blog"
3+
date: 2024-04-05
4+
---
5+
6+
First blog content.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
title: "Second Blog"
3+
date: 2024-04-06
4+
---
5+
6+
Second blog content.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
title: "Third Blog"
3+
date: 2024-04-07
4+
---
5+
6+
Third blog content.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"alt": "Futuristic landscape with chrome buildings and blue skies",
3+
"src": "../../assets/the-future.jpg"
4+
}

0 commit comments

Comments
 (0)
Please sign in to comment.