[Content collections] Load MDX hoisted scripts in dev (#6035)

* chore: script, rename delayed -> propagated

* fix: consistent propagatedAssets flag

* feat: inject those scripts in dev!

* test: scripts included in dev and build

* chore: add TODO for prod build fix

* chore: changeset
This commit is contained in:
Ben Holmes 2023-01-30 11:22:17 -05:00 committed by GitHub
parent 9bb0bfab13
commit b4432cd6b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 264 additions and 115 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Fix: Astro component scripts now load in development when using MDX + Content Collections

View file

@ -1,8 +1,9 @@
export const contentFileExts = ['.md', '.mdx'];
export const DELAYED_ASSET_FLAG = 'astroAssetSsr';
export const PROPAGATED_ASSET_FLAG = 'astroPropagatedAssets';
export const CONTENT_FLAG = 'astroContent';
export const VIRTUAL_MODULE_ID = 'astro:content';
export const LINKS_PLACEHOLDER = '@@ASTRO-LINKS@@';
export const STYLES_PLACEHOLDER = '@@ASTRO-STYLES@@';
export const SCRIPTS_PLACEHOLDER = '@@ASTRO-SCRIPTS@@';
export const CONTENT_TYPES_FILE = 'types.d.ts';

View file

@ -1,8 +1,8 @@
export { createContentTypesGenerator } from './types-generator.js';
export { contentObservable, getContentPaths, getDotAstroTypeReference } from './utils.js';
export {
astroBundleDelayedAssetPlugin,
astroDelayedAssetPlugin,
astroContentProdBundlePlugin,
astroContentAssetPropagationPlugin,
} from './vite-plugin-content-assets.js';
export { astroContentServerPlugin } from './vite-plugin-content-server.js';
export { astroContentVirtualModPlugin } from './vite-plugin-content-virtual-mod.js';

View file

@ -5,6 +5,7 @@ import {
createHeadAndContent,
renderComponent,
renderStyleElement,
renderScriptElement,
renderTemplate,
renderUniqueStylesheet,
unescapeHTML,
@ -127,7 +128,8 @@ async function render({
const Content = createComponent({
factory(result, props, slots) {
let styles = '',
links = '';
links = '',
scripts = '';
if (Array.isArray(mod?.collectedStyles)) {
styles = mod.collectedStyles.map((style: any) => renderStyleElement(style)).join('');
}
@ -140,9 +142,12 @@ async function render({
})
.join('');
}
if (Array.isArray(mod?.collectedScripts)) {
scripts = mod.collectedScripts.map((script: any) => renderScriptElement(script)).join('');
}
return createHeadAndContent(
unescapeHTML(styles + links) as any,
unescapeHTML(styles + links + scripts) as any,
renderTemplate`${renderComponent(result, 'Content', mod.Content, props, slots)}`
);
},

View file

@ -22,7 +22,7 @@ const collectionToEntryMap = createCollectionToGlobResultMap({
});
const renderEntryGlob = import.meta.glob('@@RENDER_ENTRY_GLOB_PATH@@', {
query: { astroAssetSsr: true },
query: { astroPropagatedAssets: true },
});
const collectionToRenderEntryMap = createCollectionToGlobResultMap({
globResult: renderEntryGlob,

View file

@ -5,25 +5,27 @@ import { BuildInternals, getPageDataByViteID } from '../core/build/internal.js';
import type { ModuleLoader } from '../core/module-loader/loader.js';
import { createViteLoader } from '../core/module-loader/vite.js';
import { getStylesForURL } from '../core/render/dev/css.js';
import { getScriptsForURL } from '../core/render/dev/scripts.js';
import {
contentFileExts,
DELAYED_ASSET_FLAG,
PROPAGATED_ASSET_FLAG,
LINKS_PLACEHOLDER,
SCRIPTS_PLACEHOLDER,
STYLES_PLACEHOLDER,
} from './consts.js';
function isDelayedAsset(viteId: string): boolean {
function isPropagatedAsset(viteId: string): boolean {
const url = new URL(viteId, 'file://');
return (
url.searchParams.has(DELAYED_ASSET_FLAG) &&
url.searchParams.has(PROPAGATED_ASSET_FLAG) &&
contentFileExts.some((ext) => url.pathname.endsWith(ext))
);
}
export function astroDelayedAssetPlugin({ mode }: { mode: string }): Plugin {
export function astroContentAssetPropagationPlugin({ mode }: { mode: string }): Plugin {
let devModuleLoader: ModuleLoader;
return {
name: 'astro-delayed-asset-plugin',
name: 'astro:content-asset-propagation',
enforce: 'pre',
configureServer(server) {
if (mode === 'dev') {
@ -31,19 +33,20 @@ export function astroDelayedAssetPlugin({ mode }: { mode: string }): Plugin {
}
},
load(id) {
if (isDelayedAsset(id)) {
if (isPropagatedAsset(id)) {
const basePath = id.split('?')[0];
const code = `
export { Content, getHeadings, frontmatter } from ${JSON.stringify(basePath)};
export const collectedLinks = ${JSON.stringify(LINKS_PLACEHOLDER)};
export const collectedStyles = ${JSON.stringify(STYLES_PLACEHOLDER)};
export const collectedScripts = ${JSON.stringify(SCRIPTS_PLACEHOLDER)};
`;
return { code };
}
},
async transform(code, id, options) {
if (!options?.ssr) return;
if (devModuleLoader && isDelayedAsset(id)) {
if (devModuleLoader && isPropagatedAsset(id)) {
const basePath = id.split('?')[0];
if (!devModuleLoader.getModuleById(basePath)?.ssrModule) {
await devModuleLoader.import(basePath);
@ -54,23 +57,22 @@ export function astroDelayedAssetPlugin({ mode }: { mode: string }): Plugin {
'development'
);
const hoistedScripts = await getScriptsForURL(pathToFileURL(basePath), devModuleLoader);
return {
code: code
.replace(JSON.stringify(LINKS_PLACEHOLDER), JSON.stringify([...urls]))
.replace(JSON.stringify(STYLES_PLACEHOLDER), JSON.stringify([...stylesMap.values()])),
.replace(JSON.stringify(STYLES_PLACEHOLDER), JSON.stringify([...stylesMap.values()]))
.replace(JSON.stringify(SCRIPTS_PLACEHOLDER), JSON.stringify([...hoistedScripts])),
};
}
},
};
}
export function astroBundleDelayedAssetPlugin({
internals,
}: {
internals: BuildInternals;
}): Plugin {
export function astroContentProdBundlePlugin({ internals }: { internals: BuildInternals }): Plugin {
return {
name: 'astro-bundle-delayed-asset-plugin',
name: 'astro:content-prod-bundle',
async generateBundle(_options, bundle) {
for (const [_, chunk] of Object.entries(bundle)) {
if (chunk.type === 'chunk' && chunk.code.includes(LINKS_PLACEHOLDER)) {

View file

@ -4,7 +4,7 @@ import fs from 'fs';
import { bgGreen, bgMagenta, black, dim } from 'kleur/colors';
import { fileURLToPath } from 'url';
import * as vite from 'vite';
import { astroBundleDelayedAssetPlugin } from '../../content/index.js';
import { astroContentProdBundlePlugin } from '../../content/index.js';
import {
BuildInternals,
createBuildInternals,
@ -165,7 +165,7 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp
}),
vitePluginPrerender(opts, internals),
...(viteConfig.plugins || []),
astroBundleDelayedAssetPlugin({ internals }),
astroContentProdBundlePlugin({ internals }),
// SSR needs to be last
ssr && vitePluginSSR(internals, settings.adapter!),
],

View file

@ -6,7 +6,7 @@ import { isCSSRequest } from '../render/util.js';
import type { BuildInternals } from './internal';
import type { PageBuildData, StaticBuildOptions } from './types';
import { DELAYED_ASSET_FLAG } from '../../content/consts.js';
import { PROPAGATED_ASSET_FLAG } from '../../content/consts.js';
import * as assetName from './css-asset-name.js';
import { moduleIsTopLevelPage, walkParentInfos } from './graph.js';
import {
@ -79,7 +79,7 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[]
for (const [pageInfo] of walkParentInfos(id, {
getModuleInfo: args[0].getModuleInfo,
})) {
if (new URL(pageInfo.id, 'file://').searchParams.has(DELAYED_ASSET_FLAG)) {
if (new URL(pageInfo.id, 'file://').searchParams.has(PROPAGATED_ASSET_FLAG)) {
// Split delayed assets to separate modules
// so they can be injected where needed
return createNameHash(id, [id]);
@ -172,10 +172,10 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[]
id,
this,
function until(importer) {
return new URL(importer, 'file://').searchParams.has(DELAYED_ASSET_FLAG);
return new URL(importer, 'file://').searchParams.has(PROPAGATED_ASSET_FLAG);
}
)) {
if (new URL(pageInfo.id, 'file://').searchParams.has(DELAYED_ASSET_FLAG)) {
if (new URL(pageInfo.id, 'file://').searchParams.has(PROPAGATED_ASSET_FLAG)) {
for (const parent of walkParentInfos(id, this)) {
const parentInfo = parent[0];
if (moduleIsTopLevelPage(parentInfo)) {

View file

@ -8,7 +8,7 @@ import { crawlFrameworkPkgs } from 'vitefu';
import {
astroContentServerPlugin,
astroContentVirtualModPlugin,
astroDelayedAssetPlugin,
astroContentAssetPropagationPlugin,
} from '../content/index.js';
import astroPostprocessVitePlugin from '../vite-plugin-astro-postprocess/index.js';
import { vitePluginAstroServer } from '../vite-plugin-astro-server/index.js';
@ -106,7 +106,7 @@ export async function createVite(
astroInjectEnvTsPlugin({ settings, logging, fs }),
astroContentVirtualModPlugin({ settings }),
astroContentServerPlugin({ fs, settings, logging, mode }),
astroDelayedAssetPlugin({ mode }),
astroContentAssetPropagationPlugin({ mode }),
],
publicDir: fileURLToPath(settings.config.publicDir),
root: fileURLToPath(settings.config.root),

View file

@ -1,7 +1,7 @@
import type { ModuleLoader, ModuleNode } from '../../module-loader/index';
import npath from 'path';
import { DELAYED_ASSET_FLAG } from '../../../content/consts.js';
import { PROPAGATED_ASSET_FLAG } from '../../../content/consts.js';
import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../constants.js';
import { unwrapId } from '../../util.js';
import { STYLE_EXTENSIONS } from '../util.js';
@ -23,7 +23,7 @@ export async function* crawlGraph(
): AsyncGenerator<ModuleNode, void, unknown> {
const id = unwrapId(_id);
const importedModules = new Set<ModuleNode>();
if (new URL(id, 'file://').searchParams.has(DELAYED_ASSET_FLAG)) return;
if (new URL(id, 'file://').searchParams.has(PROPAGATED_ASSET_FLAG)) return;
const moduleEntriesForId = isRootFile
? // "getModulesByFile" pulls from a delayed module cache (fun implementation detail),

View file

@ -18,6 +18,7 @@ export {
renderPage,
renderSlot,
renderStyleElement,
renderScriptElement,
renderTemplate as render,
renderTemplate,
renderToString,

View file

@ -16,6 +16,6 @@ export { renderHTMLElement } from './dom.js';
export { maybeRenderHead, renderHead } from './head.js';
export { renderPage } from './page.js';
export { renderSlot } from './slot.js';
export { renderStyleElement, renderUniqueStylesheet } from './tags.js';
export { renderStyleElement, renderScriptElement, renderUniqueStylesheet } from './tags.js';
export type { RenderInstruction } from './types';
export { addAttribute, defineScriptVars, voidElementNames } from './util.js';

View file

@ -1,4 +1,4 @@
import { SSRResult } from '../../../@types/astro';
import { SSRElement, SSRResult } from '../../../@types/astro';
import { renderElement } from './util.js';
const stylesheetRel = 'stylesheet';
@ -10,6 +10,13 @@ export function renderStyleElement(children: string) {
});
}
export function renderScriptElement({ props, children }: SSRElement) {
return renderElement('script', {
props,
children,
});
}
export function renderStylesheet({ href }: { href: string }) {
return renderElement(
'link',

View file

@ -0,0 +1,166 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture, isWindows } from './test-utils.js';
import testAdapter from './test-adapter.js';
const describe = isWindows ? global.describe.skip : global.describe;
describe('Content Collections - render()', () => {
describe('Build - SSG', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/content/',
});
await fixture.build();
});
it('Includes CSS for rendered entry', async () => {
const html = await fixture.readFile('/launch-week/index.html');
const $ = cheerio.load(html);
// Renders content
expect($('ul li')).to.have.a.lengthOf(3);
// Includes styles
expect($('link[rel=stylesheet]')).to.have.a.lengthOf(1);
});
it('Excludes CSS for non-rendered entries', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
// Excludes styles
expect($('link[rel=stylesheet]')).to.have.a.lengthOf(0);
});
it('Includes component scripts for rendered entry', async () => {
const html = await fixture.readFile('/launch-week-component-scripts/index.html');
const $ = cheerio.load(html);
const allScripts = $('head > script[type="module"]');
expect(allScripts).to.have.length;
// Includes hoisted script
expect(
[...allScripts].find((script) =>
$(script).text().includes('document.querySelector("#update-me")')
),
'`WithScripts.astro` hoisted script missing from head.'
).to.not.be.undefined;
// Includes inline script
expect($('script[data-is-inline]')).to.have.a.lengthOf(1);
});
// TODO: Script bleed isn't solved for prod builds.
// Tackling in separate PR.
it.skip('Excludes component scripts for non-rendered entries', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
const allScripts = $('head > script[type="module"]');
// Excludes hoisted script
expect(
[...allScripts].find((script) =>
$(script).text().includes('document.querySelector("#update-me")')
),
'`WithScripts.astro` hoisted script included unexpectedly.'
).to.be.undefined;
});
});
describe('Build - SSR', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
output: 'server',
root: './fixtures/content/',
adapter: testAdapter(),
});
await fixture.build();
});
it('Includes CSS for rendered entry', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/launch-week');
const response = await app.render(request);
const html = await response.text();
const $ = cheerio.load(html);
// Renders content
expect($('ul li')).to.have.a.lengthOf(3);
// Includes styles
expect($('link[rel=stylesheet]')).to.have.a.lengthOf(1);
});
it('Exclude CSS for non-rendered entries', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/');
const response = await app.render(request);
const html = await response.text();
const $ = cheerio.load(html);
// Includes styles
expect($('link[rel=stylesheet]')).to.have.a.lengthOf(0);
});
});
describe('Dev - SSG', () => {
let devServer;
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/content/',
});
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('Includes CSS for rendered entry', async () => {
const response = await fixture.fetch('/launch-week', { method: 'GET' });
expect(response.status).to.equal(200);
const html = await response.text();
const $ = cheerio.load(html);
// Renders content
expect($('ul li')).to.have.a.lengthOf(3);
// Includes styles
expect($('head > style')).to.have.a.lengthOf(1);
expect($('head > style').text()).to.include("font-family: 'Comic Sans MS'");
});
it('Includes component scripts for rendered entry', async () => {
const response = await fixture.fetch('/launch-week-component-scripts', { method: 'GET' });
expect(response.status).to.equal(200);
const html = await response.text();
const $ = cheerio.load(html);
const allScripts = $('head > script[src]');
expect(allScripts).to.have.length;
// Includes hoisted script
expect(
[...allScripts].find((script) => script.attribs.src.includes('WithScripts.astro')),
'`WithScripts.astro` hoisted script missing from head.'
).to.not.be.undefined;
// Includes inline script
expect($('script[data-is-inline]')).to.have.a.lengthOf(1);
});
});
});

View file

@ -0,0 +1,11 @@
<p id="update-me">Hoisted script didn't update me :(</p>
<p id="update-me-inline">Inline script didn't update me :(</p>
<script>
document.querySelector('#update-me').innerText = 'Updated client-side with hoisted script!';
</script>
<script is:inline data-is-inline>
document.querySelector('#update-me-inline').innerText = 'Updated client-side with inline script!';
</script>

View file

@ -0,0 +1,16 @@
---
title: 'Launch week!'
description: 'Join us for the exciting launch of SPACE BLOG'
publishedDate: 'Sat May 21 2022 00:00:00 GMT-0400 (Eastern Daylight Time)'
tags: ['announcement']
---
import WithScripts from '../../../components/WithScripts.astro';
Join us for the space blog launch!
- THIS THURSDAY
- Houston, TX
- Dress code: **interstellar casual** ✨
<WithScripts />

View file

@ -5,7 +5,7 @@ publishedDate: 'Sat May 21 2022 00:00:00 GMT-0400 (Eastern Daylight Time)'
tags: ['announcement']
---
import './launch-week-styles.css';
import './_launch-week-styles.css';
Join us for the space blog launch!

View file

@ -0,0 +1,14 @@
---
import { getEntryBySlug } from 'astro:content';
const entry = await getEntryBySlug('blog', 'promo/launch-week-component-scripts');
const { Content } = await entry.render();
---
<html>
<head>
<title>Launch Week</title>
</head>
<body>
<Content />
</body>
</html>

View file

@ -1,9 +1,8 @@
---
import { getCollection } from 'astro:content';
import { getEntryBySlug } from 'astro:content';
const blog = await getCollection('blog');
const launchWeekEntry = blog.find(post => post.id === 'promo/launch-week.mdx');
const { Content } = await launchWeekEntry.render();
const entry = await getEntryBySlug('blog', 'promo/launch-week');
const { Content } = await entry.render();
---
<html>
<head>

View file

@ -1,78 +0,0 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture, isWindows } from './test-utils.js';
import testAdapter from './test-adapter.js';
const describe = isWindows ? global.describe.skip : global.describe;
describe('Content Collections - render()', () => {
describe('Build - SSG', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/content/',
});
await fixture.build();
});
it('Page that render an entry include its CSS', async () => {
const html = await fixture.readFile('/launch-week/index.html');
const $ = cheerio.load(html);
// Renders content
expect($('ul li')).to.have.a.lengthOf(3);
// Includes styles
expect($('link[rel=stylesheet]')).to.have.a.lengthOf(1);
});
it('Page that does not render an entry does not include its CSS', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
// Includes styles
expect($('link[rel=stylesheet]')).to.have.a.lengthOf(0);
});
});
describe('Build - SSR', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
output: 'server',
root: './fixtures/content/',
adapter: testAdapter(),
});
await fixture.build();
});
it('Page that render an entry include its CSS', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/launch-week');
const response = await app.render(request);
const html = await response.text();
const $ = cheerio.load(html);
// Renders content
expect($('ul li')).to.have.a.lengthOf(3);
// Includes styles
expect($('link[rel=stylesheet]')).to.have.a.lengthOf(1);
});
it('Page that does not render an entry does not include its CSS', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/');
const response = await app.render(request);
const html = await response.text();
const $ = cheerio.load(html);
// Includes styles
expect($('link[rel=stylesheet]')).to.have.a.lengthOf(0);
});
});
});