Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 17 additions & 32 deletions packages/md/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 (/^<script\b/m.test(content)) {
return true;
}
}
}
} catch {
// ignore
}
return false;
/** The Markdown files that `environment` keeps, relative to `pagesDir`. */
async function listMdFiles(pagesDir: string, environment: 'dev' | 'prod'): Promise<Array<string>> {
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<boolean> {
for (const file of await listMdFiles(pagesDir, environment)) {
if (extractScript(readFileSync(join(pagesDir, file), 'utf-8')).clientCode) {
return true;
}
} catch {
// ignore
}
return false;
}
Expand Down Expand Up @@ -114,12 +104,7 @@ export function voidMarkdown(options?: MarkdownOptions): Array<Plugin> {
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');
Expand Down Expand Up @@ -156,7 +141,7 @@ export function voidMarkdown(options?: MarkdownOptions): Array<Plugin> {
},
},

config(config, env) {
async config(config, env) {
mdEnvironment = env.command === 'serve' && !env.isPreview ? 'dev' : 'prod';
if (env.command === 'serve') {
return;
Expand All @@ -166,7 +151,7 @@ export function voidMarkdown(options?: MarkdownOptions): Array<Plugin> {
if (!existsSync(pagesDir)) {
return;
}
if (hasMdFilesWithScript(pagesDir) && hasIslandFiles(pagesDir)) {
if (await hasMdClientCode(pagesDir, mdEnvironment)) {
return {
environments: {
client: {
Expand Down
82 changes: 82 additions & 0 deletions packages/md/test/integration/client-script-build.test.ts
Original file line number Diff line number Diff line change
@@ -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 <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}`,
'pages/script.md': `<script>
document.querySelector('#out').textContent = 'set-by-script';
</script>

# Script

<span id="out">unset</span>
`,
'pages/mixed.md': `<script>
import Counter from '../components/Counter.island.tsx' with { island: 'load' };
document.querySelector('#out').textContent = 'set-by-script';
</script>

# Mixed

<span id="out">unset</span>

<Counter />
`,
},
});
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('<script');

server = await preview({
root: build.root,
configFile: false,
logLevel: 'silent',
build: { outDir: 'dist/client' },
preview: { host: '127.0.0.1', port: 0 },
});
const base = server.resolvedUrls?.local[0];
if (!base) {
throw new Error('Preview did not report a local URL.');
}
browser = await chromium.launch();
const page = await browser.newPage();
const errors: Array<string> = [];
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);
58 changes: 46 additions & 12 deletions packages/md/test/unit/plugin-source-dir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe('voidMarkdown sourceDir support', () => {
const buildStart = getHookHandler(plugin.buildStart) as () => Promise<void>;
const load = getHookHandler(plugin.load) as (id: string) => string | undefined;

config({ root }, { command });
await config({ root }, { command });
configResolved({
root,
plugins: [{ name: 'void-react:pages' }],
Expand Down Expand Up @@ -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<string, string>): Promise<unknown> {
const root = createTempRoot();
writeFileSync(
join(root, 'src', 'pages', 'posts', 'hello.md'),
'<script>\ndocument.body.dataset.md = "ready";\n</script>\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<unknown>;
return config({ root }, { command: 'build' });
}

expect(config({ root }, { command: 'build' })).toEqual({
const CLIENT_CODE = '<script>\ndocument.body.dataset.md = "ready";\n</script>\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': `<script>\n${ISLAND_IMPORT}\nconsole.log("ready");\n</script>\n\n<Counter />\n`,
},
],
])('adds the markdown client entry for client code on %s', async (_name, pages) => {
expect(await buildConfigFor(pages)).toEqual({
environments: {
client: {
build: {
Expand All @@ -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': `<script>\n${ISLAND_IMPORT}\n</script>\n\n<Counter />\n` },
],
[
'a script tag in a code block',
{ 'hello.md': '# Hello\n\n```html\n<script>\nconsole.log("example");\n</script>\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': `<script>\n${ISLAND_IMPORT}\n</script>\n\n<Counter />\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();
});
});