Fix: component styles within imported markdown files (#3116)
* fix: replace markdown path prefix with suffix flag * fix: avoid non-encoded colons for flag * fix: remove needless ? * fix: dev server load order * fix: production build crawl dynamic imports * fix: remove unused virtual_module_id const * fix: remove unsafe "!" on getmodbyid * fix: remove needless @id path check * fix: add list of SSR-able file extensions * docs: virtual_mod_id change * fix: support id prefix on resolved ids * fix: switch to ?mdImport flag to resolve glob imports * tests: imported md styles for dev and build * chore: changeset
This commit is contained in:
parent
dfa1042f2b
commit
44bacd2011
13 changed files with 162 additions and 19 deletions
5
.changeset/tidy-poems-occur.md
Normal file
5
.changeset/tidy-poems-occur.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix: Astro components used in dynamically imported markdown (ex. Astro.glob('\*.md') will now retain their CSS styles in dev and production builds
|
|
@ -4,12 +4,21 @@ import path from 'path';
|
||||||
import { unwrapId, viteID } from '../../util.js';
|
import { unwrapId, viteID } from '../../util.js';
|
||||||
import { STYLE_EXTENSIONS } from '../util.js';
|
import { STYLE_EXTENSIONS } from '../util.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of file extensions signalling we can (and should) SSR ahead-of-time
|
||||||
|
* See usage below
|
||||||
|
*/
|
||||||
|
const fileExtensionsToSSR = new Set(['.md']);
|
||||||
|
|
||||||
/** Given a filePath URL, crawl Vite’s module graph to find all style imports. */
|
/** Given a filePath URL, crawl Vite’s module graph to find all style imports. */
|
||||||
export function getStylesForURL(filePath: URL, viteServer: vite.ViteDevServer): Set<string> {
|
export async function getStylesForURL(
|
||||||
|
filePath: URL,
|
||||||
|
viteServer: vite.ViteDevServer
|
||||||
|
): Promise<Set<string>> {
|
||||||
const importedCssUrls = new Set<string>();
|
const importedCssUrls = new Set<string>();
|
||||||
|
|
||||||
/** recursively crawl the module graph to get all style files imported by parent id */
|
/** recursively crawl the module graph to get all style files imported by parent id */
|
||||||
function crawlCSS(_id: string, isFile: boolean, scanned = new Set<string>()) {
|
async function crawlCSS(_id: string, isFile: boolean, scanned = new Set<string>()) {
|
||||||
const id = unwrapId(_id);
|
const id = unwrapId(_id);
|
||||||
const importedModules = new Set<vite.ModuleNode>();
|
const importedModules = new Set<vite.ModuleNode>();
|
||||||
const moduleEntriesForId = isFile
|
const moduleEntriesForId = isFile
|
||||||
|
@ -32,6 +41,16 @@ export function getStylesForURL(filePath: URL, viteServer: vite.ViteDevServer):
|
||||||
if (id === entry.id) {
|
if (id === entry.id) {
|
||||||
scanned.add(id);
|
scanned.add(id);
|
||||||
for (const importedModule of entry.importedModules) {
|
for (const importedModule of entry.importedModules) {
|
||||||
|
// some dynamically imported modules are *not* server rendered in time
|
||||||
|
// to only SSR modules that we can safely transform, we check against
|
||||||
|
// a list of file extensions based on our built-in vite plugins
|
||||||
|
if (importedModule.id) {
|
||||||
|
// use URL to strip special query params like "?content"
|
||||||
|
const { pathname } = new URL(`file://${importedModule.id}`);
|
||||||
|
if (fileExtensionsToSSR.has(path.extname(pathname))) {
|
||||||
|
await viteServer.ssrLoadModule(importedModule.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
importedModules.add(importedModule);
|
importedModules.add(importedModule);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,11 +67,11 @@ export function getStylesForURL(filePath: URL, viteServer: vite.ViteDevServer):
|
||||||
// NOTE: We use the `url` property here. `id` would break Windows.
|
// NOTE: We use the `url` property here. `id` would break Windows.
|
||||||
importedCssUrls.add(importedModule.url);
|
importedCssUrls.add(importedModule.url);
|
||||||
}
|
}
|
||||||
crawlCSS(importedModule.id, false, scanned);
|
await crawlCSS(importedModule.id, false, scanned);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crawl your import graph for CSS files, populating `importedCssUrls` as a result.
|
// Crawl your import graph for CSS files, populating `importedCssUrls` as a result.
|
||||||
crawlCSS(viteID(filePath), true);
|
await crawlCSS(viteID(filePath), true);
|
||||||
return importedCssUrls;
|
return importedCssUrls;
|
||||||
}
|
}
|
||||||
|
|
|
@ -139,7 +139,7 @@ export async function render(
|
||||||
// Pass framework CSS in as link tags to be appended to the page.
|
// Pass framework CSS in as link tags to be appended to the page.
|
||||||
let links = new Set<SSRElement>();
|
let links = new Set<SSRElement>();
|
||||||
if (!isLegacyBuild) {
|
if (!isLegacyBuild) {
|
||||||
[...getStylesForURL(filePath, viteServer)].forEach((href) => {
|
[...(await getStylesForURL(filePath, viteServer))].forEach((href) => {
|
||||||
if (mode === 'development' && svelteStylesRE.test(href)) {
|
if (mode === 'development' && svelteStylesRE.test(href)) {
|
||||||
scripts.add({
|
scripts.add({
|
||||||
props: { type: 'module', src: href },
|
props: { type: 'module', src: href },
|
||||||
|
@ -211,7 +211,7 @@ export async function render(
|
||||||
|
|
||||||
// inject CSS
|
// inject CSS
|
||||||
if (isLegacyBuild) {
|
if (isLegacyBuild) {
|
||||||
[...getStylesForURL(filePath, viteServer)].forEach((href) => {
|
[...(await getStylesForURL(filePath, viteServer))].forEach((href) => {
|
||||||
if (mode === 'development' && svelteStylesRE.test(href)) {
|
if (mode === 'development' && svelteStylesRE.test(href)) {
|
||||||
tags.push({
|
tags.push({
|
||||||
tag: 'script',
|
tag: 'script',
|
||||||
|
|
|
@ -74,7 +74,7 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin {
|
||||||
|
|
||||||
const info = ctx.getModuleInfo(id);
|
const info = ctx.getModuleInfo(id);
|
||||||
if (info) {
|
if (info) {
|
||||||
for (const importedId of info.importedIds) {
|
for (const importedId of [...info.importedIds, ...info.dynamicallyImportedIds]) {
|
||||||
if (!seen.has(importedId) && !isRawOrUrlModule(importedId)) {
|
if (!seen.has(importedId) && !isRawOrUrlModule(importedId)) {
|
||||||
yield* walkStyles(ctx, importedId, seen);
|
yield* walkStyles(ctx, importedId, seen);
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,8 +16,8 @@ interface AstroPluginOptions {
|
||||||
config: AstroConfig;
|
config: AstroConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIRTUAL_MODULE_ID_PREFIX = 'astro:markdown';
|
const MARKDOWN_IMPORT_FLAG = '?mdImport';
|
||||||
const VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID_PREFIX;
|
const MARKDOWN_CONTENT_FLAG = '?content';
|
||||||
|
|
||||||
// TODO: Clean up some of the shared logic between this Markdown plugin and the Astro plugin.
|
// TODO: Clean up some of the shared logic between this Markdown plugin and the Astro plugin.
|
||||||
// Both end up connecting a `load()` hook to the Astro compiler, and share some copy-paste
|
// Both end up connecting a `load()` hook to the Astro compiler, and share some copy-paste
|
||||||
|
@ -53,16 +53,12 @@ export default function markdown({ config }: AstroPluginOptions): Plugin {
|
||||||
name: 'astro:markdown',
|
name: 'astro:markdown',
|
||||||
enforce: 'pre',
|
enforce: 'pre',
|
||||||
async resolveId(id, importer, options) {
|
async resolveId(id, importer, options) {
|
||||||
// Resolve virtual modules as-is.
|
|
||||||
if (id.startsWith(VIRTUAL_MODULE_ID)) {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
// Resolve any .md files with the `?content` cache buster. This should only come from
|
// Resolve any .md files with the `?content` cache buster. This should only come from
|
||||||
// an already-resolved JS module wrapper. Needed to prevent infinite loops in Vite.
|
// an already-resolved JS module wrapper. Needed to prevent infinite loops in Vite.
|
||||||
// Unclear if this is expected or if cache busting is just working around a Vite bug.
|
// Unclear if this is expected or if cache busting is just working around a Vite bug.
|
||||||
if (id.endsWith('.md?content')) {
|
if (id.endsWith(`.md${MARKDOWN_CONTENT_FLAG}`)) {
|
||||||
const resolvedId = await this.resolve(id, importer, { skipSelf: true, ...options });
|
const resolvedId = await this.resolve(id, importer, { skipSelf: true, ...options });
|
||||||
return resolvedId?.id.replace('?content', '');
|
return resolvedId?.id.replace(MARKDOWN_CONTENT_FLAG, '');
|
||||||
}
|
}
|
||||||
// If the markdown file is imported from another file via ESM, resolve a JS representation
|
// If the markdown file is imported from another file via ESM, resolve a JS representation
|
||||||
// that defers the markdown -> HTML rendering until it is needed. This is especially useful
|
// that defers the markdown -> HTML rendering until it is needed. This is especially useful
|
||||||
|
@ -71,7 +67,7 @@ export default function markdown({ config }: AstroPluginOptions): Plugin {
|
||||||
if (id.endsWith('.md') && !isRootImport(importer)) {
|
if (id.endsWith('.md') && !isRootImport(importer)) {
|
||||||
const resolvedId = await this.resolve(id, importer, { skipSelf: true, ...options });
|
const resolvedId = await this.resolve(id, importer, { skipSelf: true, ...options });
|
||||||
if (resolvedId) {
|
if (resolvedId) {
|
||||||
return VIRTUAL_MODULE_ID + resolvedId.id;
|
return resolvedId.id + MARKDOWN_IMPORT_FLAG;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// In all other cases, we do nothing and rely on normal Vite resolution.
|
// In all other cases, we do nothing and rely on normal Vite resolution.
|
||||||
|
@ -81,11 +77,11 @@ export default function markdown({ config }: AstroPluginOptions): Plugin {
|
||||||
// A markdown file has been imported via ESM!
|
// A markdown file has been imported via ESM!
|
||||||
// Return the file's JS representation, including all Markdown
|
// Return the file's JS representation, including all Markdown
|
||||||
// frontmatter and a deferred `import() of the compiled markdown content.
|
// frontmatter and a deferred `import() of the compiled markdown content.
|
||||||
if (id.startsWith(VIRTUAL_MODULE_ID)) {
|
if (id.endsWith(`.md${MARKDOWN_IMPORT_FLAG}`)) {
|
||||||
const sitePathname = config.site
|
const sitePathname = config.site
|
||||||
? appendForwardSlash(new URL(config.base, config.site).pathname)
|
? appendForwardSlash(new URL(config.base, config.site).pathname)
|
||||||
: '/';
|
: '/';
|
||||||
const fileId = id.substring(VIRTUAL_MODULE_ID.length);
|
const fileId = id.replace(MARKDOWN_IMPORT_FLAG, '');
|
||||||
const fileUrl = fileId.includes('/pages/')
|
const fileUrl = fileId.includes('/pages/')
|
||||||
? fileId.replace(/^.*\/pages\//, sitePathname).replace(/(\/index)?\.md$/, '')
|
? fileId.replace(/^.*\/pages\//, sitePathname).replace(/(\/index)?\.md$/, '')
|
||||||
: undefined;
|
: undefined;
|
||||||
|
@ -100,7 +96,7 @@ export default function markdown({ config }: AstroPluginOptions): Plugin {
|
||||||
|
|
||||||
// Deferred
|
// Deferred
|
||||||
export default async function load() {
|
export default async function load() {
|
||||||
return (await import(${JSON.stringify(fileId + '?content')}));
|
return (await import(${JSON.stringify(fileId + MARKDOWN_CONTENT_FLAG)}));
|
||||||
};
|
};
|
||||||
export function Content(...args) {
|
export function Content(...args) {
|
||||||
return load().then((m) => m.default(...args))
|
return load().then((m) => m.default(...args))
|
||||||
|
|
59
packages/astro/test/astro-markdown-css.js
Normal file
59
packages/astro/test/astro-markdown-css.js
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import cheerio from 'cheerio';
|
||||||
|
import { loadFixture } from './test-utils.js';
|
||||||
|
|
||||||
|
let fixture;
|
||||||
|
const IMPORTED_ASTRO_COMPONENT_ID = 'imported-astro-component'
|
||||||
|
|
||||||
|
describe('Imported markdown CSS', function () {
|
||||||
|
before(async () => {
|
||||||
|
fixture = await loadFixture({ root: './fixtures/astro-markdown-css/' });
|
||||||
|
});
|
||||||
|
describe('build', () => {
|
||||||
|
let $;
|
||||||
|
let bundledCSS;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
this.timeout(45000); // test needs a little more time in CI
|
||||||
|
await fixture.build();
|
||||||
|
|
||||||
|
// get bundled CSS (will be hashed, hence DOM query)
|
||||||
|
const html = await fixture.readFile('/index.html');
|
||||||
|
$ = cheerio.load(html);
|
||||||
|
const bundledCSSHREF = $('link[rel=stylesheet][href^=/assets/]').attr('href');
|
||||||
|
bundledCSS = await fixture.readFile(bundledCSSHREF.replace(/^\/?/, '/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Compiles styles for Astro components within imported markdown', () => {
|
||||||
|
const importedAstroComponent = $(`#${IMPORTED_ASTRO_COMPONENT_ID}`)?.[0]
|
||||||
|
expect(importedAstroComponent?.name).to.equal('h2')
|
||||||
|
const cssClass = $(importedAstroComponent).attr('class')?.split(/\s+/)?.[0]
|
||||||
|
|
||||||
|
expect(bundledCSS).to.match(new RegExp(`h2.${cssClass}{color:#00f}`))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('dev', () => {
|
||||||
|
let devServer;
|
||||||
|
let $;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
devServer = await fixture.startDevServer();
|
||||||
|
const html = await fixture.fetch('/').then((res) => res.text());
|
||||||
|
$ = cheerio.load(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await devServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Compiles styles for Astro components within imported markdown', async () => {
|
||||||
|
const importedAstroComponent = $(`#${IMPORTED_ASTRO_COMPONENT_ID}`)?.[0]
|
||||||
|
expect(importedAstroComponent?.name).to.equal('h2')
|
||||||
|
const cssClass = $(importedAstroComponent).attr('class')?.split(/\s+/)?.[0]
|
||||||
|
|
||||||
|
const astroCSSHREF = $('link[rel=stylesheet][href^=/src/components/Visual.astro]').attr('href');
|
||||||
|
const css = await fixture.fetch(astroCSSHREF.replace(/^\/?/, '/')).then((res) => res.text());
|
||||||
|
expect(css).to.match(new RegExp(`h2.${cssClass}{color:#00f}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
6
packages/astro/test/fixtures/astro-markdown-css/astro.config.mjs
vendored
Normal file
6
packages/astro/test/fixtures/astro-markdown-css/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
integrations: []
|
||||||
|
});
|
12
packages/astro/test/fixtures/astro-markdown-css/package.json
vendored
Normal file
12
packages/astro/test/fixtures/astro-markdown-css/package.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "@test/astro-markdown-css",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "astro build",
|
||||||
|
"dev": "astro dev"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"astro": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
7
packages/astro/test/fixtures/astro-markdown-css/src/components/Visual.astro
vendored
Normal file
7
packages/astro/test/fixtures/astro-markdown-css/src/components/Visual.astro
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<h2 id="imported-astro-component">I'm a visual!</h2>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h2 {
|
||||||
|
color: #00f;
|
||||||
|
}
|
||||||
|
</style>
|
9
packages/astro/test/fixtures/astro-markdown-css/src/markdown/article.md
vendored
Normal file
9
packages/astro/test/fixtures/astro-markdown-css/src/markdown/article.md
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
---
|
||||||
|
setup: import Visual from '../components/Visual.astro'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Example markdown document, with a Visual
|
||||||
|
|
||||||
|
<Visual />
|
||||||
|
<Visual />
|
||||||
|
<Visual />
|
9
packages/astro/test/fixtures/astro-markdown-css/src/markdown/article2.md
vendored
Normal file
9
packages/astro/test/fixtures/astro-markdown-css/src/markdown/article2.md
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
---
|
||||||
|
setup: import Visual from '../components/Visual.astro'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Example markdown document, with a more Visuals
|
||||||
|
|
||||||
|
<Visual />
|
||||||
|
<Visual />
|
||||||
|
<Visual />
|
15
packages/astro/test/fixtures/astro-markdown-css/src/pages/index.astro
vendored
Normal file
15
packages/astro/test/fixtures/astro-markdown-css/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
---
|
||||||
|
const markdownDocs = await Astro.glob('../markdown/*.md')
|
||||||
|
const article2 = await import('../markdown/article2.md')
|
||||||
|
---
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<title>Astro</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{markdownDocs.map(markdownDoc => <><h2>{markdownDoc.url}</h2><markdownDoc.Content /></>)}
|
||||||
|
<article2.Content />
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -800,6 +800,12 @@ importers:
|
||||||
'@astrojs/preact': link:../../../../integrations/preact
|
'@astrojs/preact': link:../../../../integrations/preact
|
||||||
astro: link:../../..
|
astro: link:../../..
|
||||||
|
|
||||||
|
packages/astro/test/fixtures/astro-markdown-css:
|
||||||
|
specifiers:
|
||||||
|
astro: workspace:*
|
||||||
|
dependencies:
|
||||||
|
astro: link:../../..
|
||||||
|
|
||||||
packages/astro/test/fixtures/astro-markdown-drafts:
|
packages/astro/test/fixtures/astro-markdown-drafts:
|
||||||
specifiers:
|
specifiers:
|
||||||
astro: workspace:*
|
astro: workspace:*
|
||||||
|
|
Loading…
Reference in a new issue