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

fix(astro): handle symlinked content collection directories #11236

Merged
merged 12 commits into from
Jun 12, 2024
5 changes: 5 additions & 0 deletions .changeset/fifty-clouds-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Supports symlinked content collection directories
ascorbic marked this conversation as resolved.
Show resolved Hide resolved
44 changes: 44 additions & 0 deletions packages/astro/src/content/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,50 @@ export function getEntryConfigByExtMap<TEntryType extends ContentEntryType | Dat
return map;
}

export async function getSymlinkedContentCollections(
contentDir: URL
): Promise<Map<string, string>> {
const contentPaths = new Map<string, string>();
const contentDirPath = fileURLToPath(contentDir);

if (!fsMod.existsSync(contentDirPath) || !fsMod.lstatSync(contentDirPath).isDirectory()) {
return contentPaths;
}

const contentDirEntries = await fsMod.promises.readdir(contentDir, { withFileTypes: true });
for (const entry of contentDirEntries) {
if (entry.isSymbolicLink()) {
const entryPath = path.join(contentDirPath, entry.name);
const realPath = await fsMod.promises.realpath(entryPath);
contentPaths.set(normalizePath(realPath), entry.name);
}
}
Copy link
Member

Choose a reason for hiding this comment

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

nit: file system operations can come with runtime errors (permissions, etc.), I tend to wrap these operations in a try/catch to be more safe

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point. As a rule, would you silently ignore any errors there, log a warning, or fail the build?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'd lean towards silently ignoring them here, because we're just looking for symlinks and a failure probably means it's not a symlink.

Copy link
Member

Choose a reason for hiding this comment

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

It really depends on the use case, really. In this case we could just ignore the error, and maybe log and error/warning using the logger (you should have access to it).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Where would I find a logger instance? I'm calling this in vite-plugin-content-imports.

Copy link
Member

Choose a reason for hiding this comment

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

It seems we don't pass it down to this plugin (the logger arrived after this plugin was created). You'll have to pass it like in this case:

astroInjectEnvTsPlugin({ settings, logger, fs }),

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Aha, thanks. I just pushed an update with error handling and renamed funciton, but no logging. I'll add the logs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

OK, I've added logging now

return contentPaths;
}

export function reverseSymlinks({
Copy link
Member

Choose a reason for hiding this comment

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

nit: reverseSymlinks -> reverseSymlink singular, because it returns a string, and not a stringp[]

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The reason I did the plural is because there are multiple symlinks. But I can see what you mean.

entry,
symlinks,
contentDir,
}: {
entry: string | URL;
contentDir: string | URL;
symlinks?: Map<string, string>;
}): string {
const entryPath = normalizePath(typeof entry === 'string' ? entry : fileURLToPath(entry));
const contentDirPath = typeof contentDir === 'string' ? contentDir : fileURLToPath(contentDir);
if (!symlinks || symlinks.size === 0) {
return entryPath;
}

for (const [realPath, symlinkName] of symlinks) {
if (entryPath.startsWith(realPath)) {
return normalizePath(path.join(contentDirPath, symlinkName, entryPath.replace(realPath, '')));
}
}
return entryPath;
}

export function getEntryCollectionName({
contentDir,
entry,
Expand Down
18 changes: 15 additions & 3 deletions packages/astro/src/content/vite-plugin-content-imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ import {
getEntryConfigByExtMap,
getEntryData,
getEntryType,
getSymlinkedContentCollections,
globalContentConfigObserver,
hasContentFlag,
parseEntrySlug,
reloadContentConfigObserver,
reverseSymlinks,
} from './utils.js';

function getContentRendererByViteId(
Expand Down Expand Up @@ -75,16 +77,26 @@ export function astroContentImportPlugin({
const dataEntryConfigByExt = getEntryConfigByExtMap(settings.dataEntryTypes);
const { contentDir } = contentPaths;
let shouldEmitFile = false;

let symlinks: Map<string, string>;
const plugins: Plugin[] = [
{
name: 'astro:content-imports',
config(_config, env) {
shouldEmitFile = env.command === 'build';
},
async buildStart() {
// Get symlinks once at build start
symlinks = await getSymlinkedContentCollections(contentDir);
},
async transform(_, viteId) {
if (hasContentFlag(viteId, DATA_FLAG)) {
const fileId = viteId.split('?')[0] ?? viteId;
// By default, Vite will resolve symlinks to their targets. We need to reverse this for
// content entries, so we can get the path relative to the content directory.
const fileId = reverseSymlinks({
entry: viteId.split('?')[0] ?? viteId,
contentDir,
symlinks,
});
// Data collections don't need to rely on the module cache.
// This cache only exists for the `render()` function specific to content.
const { id, data, collection, _internal } = await getDataEntryModule({
Expand All @@ -109,7 +121,7 @@ export const _internal = {
`;
return code;
} else if (hasContentFlag(viteId, CONTENT_FLAG)) {
const fileId = viteId.split('?')[0];
const fileId = reverseSymlinks({ entry: viteId.split('?')[0], contentDir, symlinks });
const { id, slug, collection, body, data, _internal } = await getContentEntryModule({
fileId,
entryConfigByExt: contentEntryConfigByExt,
Expand Down
16 changes: 13 additions & 3 deletions packages/astro/src/core/build/static-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import { bgGreen, bgMagenta, black, green } from 'kleur/colors';
import * as vite from 'vite';
import type { RouteData } from '../../@types/astro.js';
import { PROPAGATED_ASSET_FLAG } from '../../content/consts.js';
import { hasAnyContentFlag } from '../../content/utils.js';
import {
getSymlinkedContentCollections,
hasAnyContentFlag,
reverseSymlinks,
} from '../../content/utils.js';
import {
type BuildInternals,
createBuildInternals,
Expand Down Expand Up @@ -175,7 +179,8 @@ async function ssrBuild(
const routes = Object.values(allPages).flatMap((pageData) => pageData.route);
const isContentCache = !ssr && settings.config.experimental.contentCollectionCache;
const { lastVitePlugins, vitePlugins } = await container.runBeforeHook('server', input);

const contentDir = new URL('./src/content', settings.config.root);
const symlinks = await getSymlinkedContentCollections(contentDir);
const viteBuildConfig: vite.InlineConfig = {
...viteConfig,
mode: viteConfig.mode || 'production',
Expand Down Expand Up @@ -251,7 +256,12 @@ async function ssrBuild(
chunkInfo.facadeModuleId &&
hasAnyContentFlag(chunkInfo.facadeModuleId)
) {
const [srcRelative, flag] = chunkInfo.facadeModuleId.split('/src/')[1].split('?');
const moduleId = reverseSymlinks({
symlinks,
entry: chunkInfo.facadeModuleId,
contentDir,
});
const [srcRelative, flag] = moduleId.split('/src/')[1].split('?');
if (flag === PROPAGATED_ASSET_FLAG) {
return encodeName(`${removeFileExtension(srcRelative)}.entry.mjs`);
}
Expand Down
22 changes: 22 additions & 0 deletions packages/astro/test/content-collections.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,28 @@ describe('Content Collections', () => {
subject: 'My Newsletter',
});
});

it('Handles symlinked content', async () => {
assert.ok(json.hasOwnProperty('withSymlinkedContent'));
assert.equal(Array.isArray(json.withSymlinkedContent), true);

const ids = json.withSymlinkedContent.map((item) => item.id);
assert.deepEqual(ids, ['first.md', 'second.md', 'third.md']);
assert.equal(json.withSymlinkedContent[0].data.title, 'First Blog');
});

it('Handles symlinked data', async () => {
assert.ok(json.hasOwnProperty('withSymlinkedData'));
assert.equal(Array.isArray(json.withSymlinkedData), true);

const ids = json.withSymlinkedData.map((item) => item.id);
assert.deepEqual(ids, ['welcome']);
assert.equal(
json.withSymlinkedData[0].data.alt,
'Futuristic landscape with chrome buildings and blue skies'
);
assert.notEqual(json.withSymlinkedData[0].data.src.src, undefined);
});
});

describe('Propagation', () => {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const withSchemaConfig = defineCollection({
isDraft: z.boolean().default(false),
lang: z.enum(['en', 'fr', 'es']).default('en'),
publishedAt: z.date().transform((val) => new Date(val)),
})
}),
});

const withUnionSchema = defineCollection({
Expand All @@ -28,8 +28,27 @@ const withUnionSchema = defineCollection({
]),
});

const withSymlinkedData = defineCollection({
type: 'data',
schema: ({ image }) =>
z.object({
alt: z.string(),
src: image(),
}),
});

const withSymlinkedContent = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
date: z.date(),
}),
});

export const collections = {
'with-custom-slugs': withCustomSlugs,
'with-schema-config': withSchemaConfig,
'with-union-schema': withUnionSchema,
}
'with-symlinked-data': withSymlinkedData,
'with-symlinked-content': withSymlinkedContent,
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ export async function GET() {
const withSchemaConfig = stripAllRenderFn(await getCollection('with-schema-config'));
const withSlugConfig = stripAllRenderFn(await getCollection('with-custom-slugs'));
const withUnionSchema = stripAllRenderFn(await getCollection('with-union-schema'));
const withSymlinkedContent = stripAllRenderFn(await getCollection('with-symlinked-content'));
const withSymlinkedData = stripAllRenderFn(await getCollection('with-symlinked-data'));

return new Response(
devalue.stringify({ withoutConfig, withSchemaConfig, withSlugConfig, withUnionSchema })
devalue.stringify({ withoutConfig, withSchemaConfig, withSlugConfig, withUnionSchema, withSymlinkedContent, withSymlinkedData }),
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: "First Blog"
date: 2024-04-05
---

First blog content.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: "Second Blog"
date: 2024-04-06
---

Second blog content.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: "Third Blog"
date: 2024-04-07
---

Third blog content.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"alt": "Futuristic landscape with chrome buildings and blue skies",
"src": "../../assets/the-future.jpg"
}
1 change: 0 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading