mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
* feat(web): point .jsx module previews at their HTML entry Multi-file React prototypes load .jsx modules from an HTML entry via <script type="text/babel" src>. A module previewed on its own has no standalone component, so it dead-ended on the React runtime error "No React component export found". Modules are now detected (a .jsx/.tsx referenced by a sibling HTML entry's babel script src) and handled: - The Preview shows a pointer to the HTML entry(ies) that render the module; clicking one opens that page and closes the module tab. - The Code tab still renders the raw source. - Such modules no longer auto-open as preview tabs after a write. * fix(web): bound module-detection cache, ignore commented-out scripts Review follow-up on the .jsx module preview pointer: - ProjectView: key the HTML content cache by file name with mtime stored alongside, so a rewrite replaces the file's single entry instead of leaking a new name@mtime key per revision. - extractBabelScriptSrcs: strip HTML comments before scanning so a commented-out babel script is not collected as a live reference. - i18n: normalize the three new jsxModule values to single quotes across all 19 locales to match each file's existing style.
155 lines
5.7 KiB
TypeScript
155 lines
5.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
collectReferencedJsxNames,
|
|
extractBabelScriptSrcs,
|
|
findHtmlEntriesReferencing,
|
|
htmlLoadsJsxModule,
|
|
isJsxModule,
|
|
} from '../../src/runtime/jsx-module-refs';
|
|
|
|
const MULTI_FILE_HTML = `<!doctype html>
|
|
<html>
|
|
<head><title>Backups Panel</title></head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js"></script>
|
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"></script>
|
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"></script>
|
|
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
|
<script type="text/babel" src="icons.jsx"></script>
|
|
<script type="text/babel" src="chrome.jsx"></script>
|
|
<script type="text/babel" src="app.jsx"></script>
|
|
</body>
|
|
</html>`;
|
|
|
|
describe('extractBabelScriptSrcs', () => {
|
|
it('returns [] for empty / nullish input', () => {
|
|
expect(extractBabelScriptSrcs('')).toEqual([]);
|
|
expect(extractBabelScriptSrcs(null)).toEqual([]);
|
|
expect(extractBabelScriptSrcs(undefined)).toEqual([]);
|
|
});
|
|
|
|
it('lists only text/babel module srcs, in document order', () => {
|
|
expect(extractBabelScriptSrcs(MULTI_FILE_HTML)).toEqual([
|
|
'tweaks-panel.jsx',
|
|
'icons.jsx',
|
|
'chrome.jsx',
|
|
'app.jsx',
|
|
]);
|
|
});
|
|
|
|
it('ignores CDN/library <script src> that are not type=text/babel', () => {
|
|
expect(extractBabelScriptSrcs(MULTI_FILE_HTML)).not.toContain(
|
|
'https://unpkg.com/react@18.3.1/umd/react.development.js',
|
|
);
|
|
});
|
|
|
|
it('ignores inline text/babel scripts with no src', () => {
|
|
const html = '<script type="text/babel">function App(){return null;}</script>';
|
|
expect(extractBabelScriptSrcs(html)).toEqual([]);
|
|
});
|
|
|
|
it('handles src-before-type attribute order', () => {
|
|
const html = '<script src="app.jsx" type="text/babel"></script>';
|
|
expect(extractBabelScriptSrcs(html)).toEqual(['app.jsx']);
|
|
});
|
|
|
|
it('normalizes a leading ./ and strips query/hash', () => {
|
|
const html =
|
|
'<script type="text/babel" src="./icons.jsx?v=2"></script>' +
|
|
'<script type="text/babel" src="chrome.jsx#frag"></script>';
|
|
expect(extractBabelScriptSrcs(html)).toEqual(['icons.jsx', 'chrome.jsx']);
|
|
});
|
|
|
|
it('ignores babel scripts commented out in HTML', () => {
|
|
const html =
|
|
'<!-- <script type="text/babel" src="legacy.jsx"></script> -->' +
|
|
'<script type="text/babel" src="app.jsx"></script>';
|
|
expect(extractBabelScriptSrcs(html)).toEqual(['app.jsx']);
|
|
expect(extractBabelScriptSrcs(html)).not.toContain('legacy.jsx');
|
|
});
|
|
});
|
|
|
|
describe('htmlLoadsJsxModule', () => {
|
|
it('matches an exact src reference', () => {
|
|
expect(htmlLoadsJsxModule(MULTI_FILE_HTML, 'icons.jsx')).toBe(true);
|
|
});
|
|
|
|
it('matches by basename when the project name has no slash', () => {
|
|
const html = '<script type="text/babel" src="parts/icons.jsx"></script>';
|
|
expect(htmlLoadsJsxModule(html, 'icons.jsx')).toBe(true);
|
|
});
|
|
|
|
it('is false when the module is not referenced', () => {
|
|
expect(htmlLoadsJsxModule(MULTI_FILE_HTML, 'unused.jsx')).toBe(false);
|
|
});
|
|
|
|
it('is false for an empty module name', () => {
|
|
expect(htmlLoadsJsxModule(MULTI_FILE_HTML, '')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('findHtmlEntriesReferencing', () => {
|
|
it('returns every HTML entry that loads the module, in map order', () => {
|
|
const sources = new Map<string, string>([
|
|
['Backups Panel.html', MULTI_FILE_HTML],
|
|
['Overview Panel.html', '<script type="text/babel" src="icons.jsx"></script>'],
|
|
['Unrelated.html', '<script type="text/babel" src="other.jsx"></script>'],
|
|
]);
|
|
expect(findHtmlEntriesReferencing('icons.jsx', sources)).toEqual([
|
|
'Backups Panel.html',
|
|
'Overview Panel.html',
|
|
]);
|
|
});
|
|
|
|
it('returns [] when no HTML references the module (standalone artifact)', () => {
|
|
const sources = new Map<string, string>([['Page.html', '<div>no scripts</div>']]);
|
|
expect(findHtmlEntriesReferencing('icons.jsx', sources)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('isJsxModule', () => {
|
|
it('is true when at least one HTML entry loads the file', () => {
|
|
const sources = new Map<string, string>([['Backups Panel.html', MULTI_FILE_HTML]]);
|
|
expect(isJsxModule('app.jsx', sources)).toBe(true);
|
|
});
|
|
|
|
it('is false when nothing references the file', () => {
|
|
const sources = new Map<string, string>([['Backups Panel.html', MULTI_FILE_HTML]]);
|
|
expect(isJsxModule('standalone-component.jsx', sources)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('collectReferencedJsxNames', () => {
|
|
const files = [
|
|
{ name: 'Backups Panel.html' },
|
|
{ name: 'tweaks-panel.jsx' },
|
|
{ name: 'icons.jsx' },
|
|
{ name: 'chrome.jsx' },
|
|
{ name: 'app.jsx' },
|
|
{ name: 'standalone.jsx' },
|
|
{ name: 'styles.css' },
|
|
];
|
|
|
|
it('returns project file names loaded by an HTML entry, excluding unreferenced ones', async () => {
|
|
const read = async (name: string) =>
|
|
name === 'Backups Panel.html' ? MULTI_FILE_HTML : null;
|
|
const result = await collectReferencedJsxNames(files, read);
|
|
expect(result).toEqual(new Set(['tweaks-panel.jsx', 'icons.jsx', 'chrome.jsx', 'app.jsx']));
|
|
// A .jsx that no HTML loads stays a normal standalone artifact.
|
|
expect(result.has('standalone.jsx')).toBe(false);
|
|
});
|
|
|
|
it('ignores <script src> that point at files the project does not have', async () => {
|
|
const read = async () => '<script type="text/babel" src="ghost.jsx"></script>';
|
|
const result = await collectReferencedJsxNames(files, read);
|
|
expect(result.size).toBe(0);
|
|
});
|
|
|
|
it('returns an empty set when no HTML entries exist', async () => {
|
|
const read = async () => null;
|
|
const result = await collectReferencedJsxNames([{ name: 'only.jsx' }], read);
|
|
expect(result).toEqual(new Set());
|
|
});
|
|
});
|