Skip to content
Open
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
65 changes: 62 additions & 3 deletions bin/pest-tia-vite-deps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,46 @@ export async function loadAliasFromViteConfig(projectRoot = PROJECT_ROOT) {
return alias
}

const SFC_RE = /\.(vue|svelte)$/i
const SCRIPT_BLOCK_RE = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi
const LANG_ATTR_RE = /\blang\s*=\s*['"]?([a-z]+)['"]?/i
const SRC_ATTR_RE = /\bsrc\s*=\s*['"]([^'"]+)['"]/i

// A Single-File Component is not JavaScript, so the parser rejects the raw file.
// The graph only needs the import edges, and every one of them lives in a
// `<script>` block, so the concatenated blocks carry the exact same edges the
// framework compiler would emit, with no compile step and no extra dependency.
export function extractSfcScript(source) {
const chunks = []
let typescript = false
let jsx = false

for (const match of source.matchAll(SCRIPT_BLOCK_RE)) {
const attrs = match[1] ?? ''
const lang = LANG_ATTR_RE.exec(attrs)?.[1]?.toLowerCase()

if (lang === 'ts' || lang === 'tsx') typescript = true
if (lang === 'jsx' || lang === 'tsx') jsx = true

const src = SRC_ATTR_RE.exec(attrs)?.[1]
if (src) { chunks.push(`import ${JSON.stringify(src)}`); continue }

chunks.push(match[2] ?? '')
}

let code = chunks.join('\n;\n')

// `<script setup>` compiles to a default export the block itself never holds,
// and an importer of the component asks for that export by name.
if (!/\bexport\s+default\b/.test(code)) code += '\nexport default null'

const moduleType = typescript
? (jsx ? 'tsx' : 'ts')
: (jsx ? 'jsx' : 'js')

return { code, moduleType }
}

async function listPageFiles(pagesDir) {
if (!existsSync(pagesDir)) return []

Expand Down Expand Up @@ -289,12 +329,28 @@ async function main() {
},
}

const sfcScript = {
name: 'pest-tia-sfc-script',
async load(id) {
if (!id || !SFC_RE.test(id)) return null

let source
try { source = await readFile(id, 'utf8') } catch { return null }

const { code, moduleType } = extractSfcScript(source)

return { code, moduleType, moduleSideEffects: false }
},
}

const assetStub = {
name: 'pest-tia-asset-stub',
load(id) {
if (!id) return null
if (ASSET_EXT_RE.test(id)) {
return { code: 'export default null', moduleSideEffects: false }
// The module type follows the extension unless a plugin overrides it,
// and rolldown refuses to bundle a CSS module at all.
return { code: 'export default null', moduleType: 'js', moduleSideEffects: false }
}
return null
},
Expand All @@ -310,9 +366,12 @@ async function main() {
alias,
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'],
},
transform: { jsx: 'preserve' },
// TypeScript drops an import whose bindings the emitted code does not use,
// and a component that only appears in an SFC template is exactly that, so
// the graph loses the edge unless the value import survives the transform.
transform: { jsx: 'preserve', typescript: { onlyRemoveTypeImports: true } },
treeshake: false,
plugins: [externalBare, assetStub, collector],
plugins: [externalBare, sfcScript, assetStub, collector],
logLevel: 'silent',
onLog: () => {},
})
Expand Down
268 changes: 268 additions & 0 deletions tests/Unit/Plugins/Tia/ViteDepsHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -472,3 +472,271 @@ function tiaViteCasingResults(): array

expect(tiaViteCasingResults()[$name])->toBe($expected);
})->with(array_keys(tiaViteCasingFixtures()));

function tiaSfcFixtures(): array
{
return [
'script-setup' => [
<<<'VUE'
<template><div /></template>
<script setup>
import Layout from '@/Layouts/AppLayout.vue'
</script>
VUE,
'js',
["import Layout from '@/Layouts/AppLayout.vue'", 'export default null'],
['<template>'],
1,
],
'script-setup-typescript' => [
<<<'VUE'
<template><div /></template>
<script setup lang="ts">
import type { Page } from '@inertiajs/core'
import Card from './Card.vue'
</script>
VUE,
'ts',
["import Card from './Card.vue'", 'export default null'],
[],
1,
],
'render-function-jsx' => [
<<<'VUE'
<script lang="tsx">
import Button from './Button.vue'

export default { render: (): unknown => <Button /> }
</script>
VUE,
'tsx',
["import Button from './Button.vue'", '<Button />'],
['export default null'],
1,
],
'options-api-keeps-its-own-default' => [
<<<'VUE'
<template><div /></template>
<script>
import Card from './Card.vue'

export default { components: { Card } }
</script>
VUE,
'js',
["import Card from './Card.vue'", 'export default { components: { Card } }'],
['export default null'],
1,
],
'both-script-blocks' => [
<<<'VUE'
<script>
import Base from './Base.vue'

export default { inheritAttrs: false }
</script>
<script setup>
import Icon from './Icon.vue'
</script>
VUE,
'js',
["import Base from './Base.vue'", "import Icon from './Icon.vue'"],
['export default null'],
1,
],
'external-script-src' => [
<<<'VUE'
<template><div /></template>
<script src="./external.js"></script>
VUE,
'js',
['import "./external.js"', 'export default null'],
[],
1,
],
'leading-html-comment' => [
<<<'VUE'
<!-- eslint-disable no-undef -->
<template><div /></template>
<script setup>
import Card from './Card.vue'
</script>
VUE,
'js',
["import Card from './Card.vue'"],
['<!--', 'eslint-disable'],
1,
],
'svelte-component' => [
<<<'SVELTE'
<script lang="ts">
import Nested from './Nested.svelte'
</script>

<Nested />
SVELTE,
'ts',
["import Nested from './Nested.svelte'", 'export default null'],
['<Nested />'],
1,
],
'no-script-block' => [
'<template><div>static</div></template>',
'js',
['export default null'],
['<template>'],
1,
],
];
}

function tiaSfcResults(): array
{
static $cache = null;
if ($cache !== null) {
return $cache;
}

$payload = [];
foreach (tiaSfcFixtures() as $name => [$source]) {
$payload[] = ['name' => $name, 'source' => $source];
}

$inputFile = tempnam(sys_get_temp_dir(), 'tia-sfc-');
file_put_contents($inputFile, json_encode($payload));

$helper = str_replace('\\', '/', tiaViteHelperPath());
$input = str_replace('\\', '/', $inputFile);

$script = <<<JS
import { extractSfcScript } from '{$helper}'
import { readFileSync } from 'node:fs'
const cases = JSON.parse(readFileSync('{$input}', 'utf8'))
const out = {}
for (const c of cases) out[c.name] = extractSfcScript(c.source)
process.stdout.write(JSON.stringify(out))
JS;

$process = new Process(['node', '--input-type=module', '-e', $script]);
$process->mustRun();

@unlink($inputFile);

return $cache = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR);
}

function tiaSfcBundleResult(): array
{
static $cache = null;
if ($cache !== null) {
return $cache;
}

$root = sys_get_temp_dir().'/pest-tia-sfc-'.bin2hex(random_bytes(6));
mkdir($root, 0755, true);
$root = realpath($root);
$pages = $root.'/resources/js/Pages/Auth';
$layouts = $root.'/resources/js/Layouts';
mkdir($pages, 0755, true);
mkdir($layouts, 0755, true);

file_put_contents($root.'/package.json', tiaJson(['name' => 'tia-sfc-fixture']));
file_put_contents($root.'/jsconfig.json', tiaJson([
'compilerOptions' => ['baseUrl' => '.', 'paths' => ['@/*' => ['./resources/js/*']]],
]));

file_put_contents($layouts.'/layout.css', '.a { color: red }');
file_put_contents($layouts.'/types.ts', "export type Page = { id: number }\n");

file_put_contents($layouts.'/Button.vue', <<<'VUE'
<template><button /></template>
<script>
export default { name: 'Button' }
</script>
VUE);

file_put_contents($layouts.'/GuestLayout.vue', <<<'VUE'
<template><slot /></template>
<script setup lang="ts">
import type { Page } from './types'
import Button from './Button.vue'
import './layout.css'
</script>
<style scoped>.a { color: red }</style>
VUE);

file_put_contents($pages.'/Login.vue', <<<'VUE'
<!-- eslint-disable no-undef -->
<template><GuestLayout /></template>
<script setup>
import GuestLayout from '@/Layouts/GuestLayout.vue'
</script>
VUE);

file_put_contents($pages.'/Register.vue', <<<'VUE'
<template><GuestLayout /></template>
<script setup lang="ts">
import GuestLayout from '@/Layouts/GuestLayout.vue'
</script>
VUE);

file_put_contents($pages.'/Widget.vue', <<<'VUE'
<script lang="tsx">
import Button from '@/Layouts/Button.vue'

export default { render: (): unknown => <Button /> }
</script>
VUE);

$process = new Process(['node', tiaViteHelperPath(), $root], $root);
$process->run();

$output = $process->getOutput();

return $cache = [
'root' => $root,
'exitCode' => $process->getExitCode(),
'errorOutput' => $process->getErrorOutput(),
'map' => $output === '' ? null : json_decode($output, true),
];
}

it('extracts the script blocks of a single-file component', function (string $name): void {
[, $moduleType, $contains, $missing, $defaults] = tiaSfcFixtures()[$name];
$result = tiaSfcResults()[$name];

expect($result['moduleType'])->toBe($moduleType);

foreach ($contains as $needle) {
expect($result['code'])->toContain($needle);
}

foreach ($missing as $needle) {
expect($result['code'])->not->toContain($needle);
}

expect(substr_count($result['code'], 'export default'))->toBe($defaults);
})->with(array_keys(tiaSfcFixtures()));

it('bundles a single-file component tree without a framework plugin', function (): void {
$result = tiaSfcBundleResult();

$probe = new Process(['node', '-e', "require.resolve('rolldown')"], $result['root']);
$probe->run();

if (! $probe->isSuccessful()) {
$this->markTestSkipped('rolldown is not installed.');
}

$pages = ['Auth/Login', 'Auth/Register'];

expect($result['exitCode'])->toBe(0, $result['errorOutput'])
->and($result['map'])->toHaveKeys([
'resources/js/Layouts/GuestLayout.vue',
'resources/js/Layouts/Button.vue',
'resources/js/Layouts/layout.css',
])
->and($result['map']['resources/js/Layouts/GuestLayout.vue'])->toBe($pages)
->and($result['map']['resources/js/Layouts/Button.vue'])->toBe([...$pages, 'Auth/Widget'])
->and($result['map']['resources/js/Layouts/layout.css'])->toBe($pages);
});