Skip to content

Commit

Permalink
fix(astro): handle symlinked content collection directories (#11236)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Changes from review

* Add logging

---------

Co-authored-by: Emanuele Stoppa <[email protected]>
  • Loading branch information
ascorbic and ematipico authored Jun 12, 2024
1 parent 2851b0a commit 39bc3a5
Show file tree
Hide file tree
Showing 15 changed files with 174 additions and 14 deletions.
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
---

Fixes a case where symlinked content collection directories were not correctly resolved
63 changes: 62 additions & 1 deletion packages/astro/src/content/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { AstroError, AstroErrorData, MarkdownError, errorMap } from '../core/err
import { isYAMLException } from '../core/errors/utils.js';
import { CONTENT_FLAGS, PROPAGATED_ASSET_FLAG } from './consts.js';
import { createImage } from './runtime-assets.js';

import type { Logger } from "../core/logger/core.js";
/**
* Amap from a collection + slug to the local file path.
* This is used internally to resolve entry imports when using `getEntry()`.
Expand Down Expand Up @@ -167,6 +167,67 @@ export function getEntryConfigByExtMap<TEntryType extends ContentEntryType | Dat
return map;
}

export async function getSymlinkedContentCollections({
contentDir,
logger,
fs
}: {
contentDir: URL;
logger: Logger;
fs: typeof fsMod;
}): Promise<Map<string, string>> {
const contentPaths = new Map<string, string>();
const contentDirPath = fileURLToPath(contentDir);
try {
if (!fs.existsSync(contentDirPath) || !fs.lstatSync(contentDirPath).isDirectory()) {
return contentPaths;
}
} catch {
// Ignore if there isn't a valid content directory
return contentPaths;
}
try {
const contentDirEntries = await fs.promises.readdir(contentDir, { withFileTypes: true });
for (const entry of contentDirEntries) {
if (entry.isSymbolicLink()) {
const entryPath = path.join(contentDirPath, entry.name);
const realPath = await fs.promises.realpath(entryPath);
contentPaths.set(normalizePath(realPath), entry.name);
}
}
} catch (e) {
logger.warn('content', `Error when reading content directory "${contentDir}"`);
logger.debug('content', e);
// If there's an error, return an empty map
return new Map<string, string>();
}

return contentPaths;
}

export function reverseSymlink({
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
21 changes: 18 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,11 +28,14 @@ import {
getEntryConfigByExtMap,
getEntryData,
getEntryType,
getSymlinkedContentCollections,
globalContentConfigObserver,
hasContentFlag,
parseEntrySlug,
reloadContentConfigObserver,
reverseSymlink,
} from './utils.js';
import type { Logger } from '../core/logger/core.js';

function getContentRendererByViteId(
viteId: string,
Expand Down Expand Up @@ -63,9 +66,11 @@ const COLLECTION_TYPES_TO_INVALIDATE_ON = ['data', 'content', 'config'];
export function astroContentImportPlugin({
fs,
settings,
logger,
}: {
fs: typeof fsMod;
settings: AstroSettings;
logger: Logger;
}): Plugin[] {
const contentPaths = getContentPaths(settings.config, fs);
const contentEntryExts = getContentEntryExts(settings);
Expand All @@ -75,16 +80,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, logger, fs });
},
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 = reverseSymlink({
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 +124,7 @@ export const _internal = {
`;
return code;
} else if (hasContentFlag(viteId, CONTENT_FLAG)) {
const fileId = viteId.split('?')[0];
const fileId = reverseSymlink({ entry: viteId.split('?')[0], contentDir, symlinks });
const { id, slug, collection, body, data, _internal } = await getContentEntryModule({
fileId,
entryConfigByExt: contentEntryConfigByExt,
Expand Down
24 changes: 18 additions & 6 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,
reverseSymlink,
} from '../../content/utils.js';
import {
type BuildInternals,
createBuildInternals,
Expand Down Expand Up @@ -36,9 +40,10 @@ import { RESOLVED_SPLIT_MODULE_ID, RESOLVED_SSR_VIRTUAL_MODULE_ID } from './plug
import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js';
import type { StaticBuildOptions } from './types.js';
import { encodeName, getTimeStat, viteBuildReturnToRollupOutputs } from './util.js';
import type { Logger } from '../logger/core.js';

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

settings.timer.end('SSR build');
Expand Down Expand Up @@ -166,7 +171,8 @@ async function ssrBuild(
opts: StaticBuildOptions,
internals: BuildInternals,
input: Set<string>,
container: AstroBuildPluginContainer
container: AstroBuildPluginContainer,
logger: Logger
) {
const buildID = Date.now().toString();
const { allPages, settings, viteConfig } = opts;
Expand All @@ -175,7 +181,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, logger, fs });
const viteBuildConfig: vite.InlineConfig = {
...viteConfig,
mode: viteConfig.mode || 'production',
Expand Down Expand Up @@ -251,7 +258,12 @@ async function ssrBuild(
chunkInfo.facadeModuleId &&
hasAnyContentFlag(chunkInfo.facadeModuleId)
) {
const [srcRelative, flag] = chunkInfo.facadeModuleId.split('/src/')[1].split('?');
const moduleId = reverseSymlink({
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
2 changes: 1 addition & 1 deletion packages/astro/src/core/create-vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export async function createVite(
astroScannerPlugin({ settings, logger }),
astroInjectEnvTsPlugin({ settings, logger, fs }),
astroContentVirtualModPlugin({ fs, settings }),
astroContentImportPlugin({ fs, settings }),
astroContentImportPlugin({ fs, settings, logger }),
astroContentAssetPropagationPlugin({ mode, settings }),
vitePluginMiddleware({ settings }),
vitePluginSSRManifest(),
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"
}

0 comments on commit 39bc3a5

Please sign in to comment.