diff --git a/packages/md/src/plugin.ts b/packages/md/src/plugin.ts index 84b74ee8a..30699d528 100644 --- a/packages/md/src/plugin.ts +++ b/packages/md/src/plugin.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'pathslash'; import { glob } from 'tinyglobby'; import { normalizePath, type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite'; @@ -57,32 +57,22 @@ function mdRouteName(file: string, env: 'dev' | 'prod' | null): string { return name.endsWith('/index') ? name.slice(0, -'/index'.length) : name; } -function hasMdFilesWithScript(dir: string): boolean { - try { - for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) { - if (entry.isFile() && entry.name.endsWith('.md')) { - const fullPath = join(entry.parentPath, entry.name); - const content = readFileSync(fullPath, 'utf-8'); - if (/^> { + const mdFiles = await glob('**/*.md', { cwd: pagesDir }); + // Skip pages for the other environment, so their scripts are not added either. + return mdFiles.filter((file) => { + const fileEnv = pageFileEnvironment(file); + return !fileEnv || fileEnv === environment; + }); } -function hasIslandFiles(dir: string): boolean { - try { - for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) { - if (entry.isFile() && /\.island\./.test(entry.name)) { - return true; - } +/** Whether any page has client code. Without it, pages would load an empty client entry. */ +async function hasMdClientCode(pagesDir: string, environment: 'dev' | 'prod'): Promise { + for (const file of await listMdFiles(pagesDir, environment)) { + if (extractScript(readFileSync(join(pagesDir, file), 'utf-8')).clientCode) { + return true; } - } catch { - // ignore } return false; } @@ -114,12 +104,7 @@ export function voidMarkdown(options?: MarkdownOptions): Array { return []; } - const mdFiles = await glob('**/*.md', { cwd: pagesDir }); - // Skip pages for the other environment, so their scripts are not added either. - const liveFiles = mdFiles.filter((file) => { - const fileEnv = pageFileEnvironment(file); - return !fileEnv || fileEnv === mdEnvironment; - }); + const liveFiles = await listMdFiles(pagesDir, mdEnvironment); const pages = await Promise.all( liveFiles.map(async (file) => { const content = readFileSync(join(pagesDir, file), 'utf-8'); @@ -156,7 +141,7 @@ export function voidMarkdown(options?: MarkdownOptions): Array { }, }, - config(config, env) { + async config(config, env) { mdEnvironment = env.command === 'serve' && !env.isPreview ? 'dev' : 'prod'; if (env.command === 'serve') { return; @@ -166,7 +151,7 @@ export function voidMarkdown(options?: MarkdownOptions): Array { if (!existsSync(pagesDir)) { return; } - if (hasMdFilesWithScript(pagesDir) && hasIslandFiles(pagesDir)) { + if (await hasMdClientCode(pagesDir, mdEnvironment)) { return { environments: { client: { diff --git a/packages/md/test/integration/client-script-build.test.ts b/packages/md/test/integration/client-script-build.test.ts new file mode 100644 index 000000000..97d5e200b --- /dev/null +++ b/packages/md/test/integration/client-script-build.test.ts @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { chromium, type Browser } from 'playwright'; +import { preview, type PreviewServer } from 'vite'; +import { expect, it } from 'vite-plus/test'; +import { waitForIslandsHydrated } from '../../../../test/browser-helpers.ts'; +import { buildIslandApp } from '../../../../test/island-css-build.ts'; + +it('runs client scripts in a production build with no .island. file in pages/', async () => { + const build = buildIslandApp({ + packageDir: join(import.meta.dirname, '../../../react'), + pluginName: 'voidReact', + files: { + 'tsconfig.json': JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }), + 'components/Counter.island.tsx': `import { useState } from 'react'; +export default function Counter() { + const [count, setCount] = useState(0); + return ; +}`, + 'pages/script.md': ` + +# Script + +unset +`, + 'pages/mixed.md': ` + +# Mixed + +unset + + +`, + }, + }); + let server: PreviewServer | undefined; + let browser: Browser | undefined; + try { + const manifest = JSON.parse( + readFileSync(join(build.root, 'dist/client/.vite/manifest.json'), 'utf8'), + ); + expect(manifest).toHaveProperty(['virtual:void-md-client']); + // A page without client code still ships no JavaScript. + expect(build.page('about.html').html).not.toContain(' = []; + page.on('pageerror', (error) => errors.push(error.message)); + + await page.goto(new URL('script', base).href); + await expect.poll(() => page.textContent('#out'), { timeout: 15_000 }).toBe('set-by-script'); + + await page.goto(new URL('mixed', base).href); + await expect.poll(() => page.textContent('#out'), { timeout: 15_000 }).toBe('set-by-script'); + await waitForIslandsHydrated(page, 1, 15_000); + await page.getByRole('button', { name: 'Count: 0', exact: true }).click(); + await expect.poll(() => page.getByRole('button').textContent()).toBe('Count: 1'); + + expect(errors).toEqual([]); + } finally { + await browser?.close(); + await server?.close(); + build.cleanup(); + } +}, 120_000); diff --git a/packages/md/test/unit/plugin-source-dir.test.ts b/packages/md/test/unit/plugin-source-dir.test.ts index 0ca6ebbce..5f1e413fc 100644 --- a/packages/md/test/unit/plugin-source-dir.test.ts +++ b/packages/md/test/unit/plugin-source-dir.test.ts @@ -77,7 +77,7 @@ describe('voidMarkdown sourceDir support', () => { const buildStart = getHookHandler(plugin.buildStart) as () => Promise; const load = getHookHandler(plugin.load) as (id: string) => string | undefined; - config({ root }, { command }); + await config({ root }, { command }); configResolved({ root, plugins: [{ name: 'void-react:pages' }], @@ -121,21 +121,32 @@ describe('voidMarkdown sourceDir support', () => { expect((await scanForCommand(root, 'build')).paths).toEqual(['/posts/stats']); }); - it('adds the markdown client entry for sourceDir pages with scripts and islands', () => { + function buildConfigFor(pages: Record): Promise { const root = createTempRoot(); - writeFileSync( - join(root, 'src', 'pages', 'posts', 'hello.md'), - '\n\n# Hello\n', - ); - writeFileSync(join(root, 'src', 'pages', 'posts', 'Counter.island.tsx'), 'export default {};'); - - const plugin = createPlugin(); - const config = getHookHandler(plugin.config) as ( + for (const [file, content] of Object.entries(pages)) { + writeFileSync(join(root, 'src', 'pages', file), content); + } + const config = getHookHandler(createPlugin().config) as ( config: { root: string }, env: { command: 'build' }, - ) => unknown; + ) => Promise; + return config({ root }, { command: 'build' }); + } - expect(config({ root }, { command: 'build' })).toEqual({ + const CLIENT_CODE = '\n\n# Hello\n'; + const ISLAND_IMPORT = 'import Counter from "./Counter.tsx" with { island: "load" };'; + + it.each([ + ['a page', { 'posts/hello.md': CLIENT_CODE }], + ['a build-only page', { 'stats.prod.md': CLIENT_CODE }], + [ + 'a page that also imports an island', + { + 'mixed.md': `\n\n\n`, + }, + ], + ])('adds the markdown client entry for client code on %s', async (_name, pages) => { + expect(await buildConfigFor(pages)).toEqual({ environments: { client: { build: { @@ -147,4 +158,27 @@ describe('voidMarkdown sourceDir support', () => { }, }); }); + + it.each([ + ['pages without a script', { 'hello.md': '# Hello\n' }], + [ + 'a script with only island imports', + { 'hello.md': `\n\n\n` }, + ], + [ + 'a script tag in a code block', + { 'hello.md': '# Hello\n\n```html\n\n```\n' }, + ], + ['client code on a dev-only page', { 'debug.dev.md': CLIENT_CODE, 'hello.md': '# Hello\n' }], + [ + 'island files next to pages without client code', + { + 'hello.md': `\n\n\n`, + 'Counter.island.tsx': 'export default {};', + '_x.island.txt': '', + }, + ], + ])('does not add the markdown client entry for %s', async (_name, pages) => { + expect(await buildConfigFor(pages)).toBeUndefined(); + }); });