Feat: define routes and ignore route files (#69)

* feat: support define routes

* fix: test

* fix: test

* chore: undefined type

* fix: conflict

* chore: remove pages str from route id

* fix: watch route change

* fix: warn

* fix: test

* fix: test

* chore: example

* chore: add route-gen example

* feat: add integration test

* chore: test

* chore: update config file

* chore: remove pnpm cache

* chore: test

* chore: remove test:ci from ci workflow

* chore: update build-scripts version

* chore: build fixture

* chore: remove devServer test

* chore: buildFixture

* feat: add vitest

* chore: add ts-ignore

* chore: node ci version

* chore: comment bundle analyzer

* chore: add test timeout

* fix: lint

* chore: remove threads

* chore: set maxThreads and minThreads

* chore: add maxConcurrency

* chore: remove coverage

* chore: threads

* chore: set threads to false

* fix: conflict

* fix: comment
This commit is contained in:
luhc228 2022-04-14 17:16:51 +08:00 committed by GitHub
parent 95d49c46d5
commit d19f21e8b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1029 additions and 793 deletions

View File

@ -0,0 +1,14 @@
import { defineConfig } from '@ice/app';
export default defineConfig({
routes: {
ignoreFiles: ['about.tsx', 'products.tsx'],
defineRoutes: (route) => {
route('/about-me', 'about.tsx');
route('/', 'layout.tsx', () => {
route('/product', 'products.tsx');
});
},
},
});

View File

@ -0,0 +1,23 @@
{
"name": "basic-project",
"version": "1.0.0",
"scripts": {
"start": "ice start",
"build": "ice build"
},
"description": "",
"author": "",
"license": "MIT",
"dependencies": {
"@ice/app": "file:../../packages/ice",
"@ice/runtime": "^1.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11",
"browserslist": "^4.19.3",
"regenerator-runtime": "^0.13.9"
}
}

View File

@ -0,0 +1,3 @@
import { defineAppConfig } from 'ice';
export default defineAppConfig({});

View File

@ -0,0 +1,26 @@
/* eslint-disable react/self-closing-comp */
import React from 'react';
import { Meta, Title, Links, Main, Scripts } from 'ice';
function Document(props) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="description" content="ICE 3.0 Demo" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Title />
<Links />
</head>
<body>
<Main>
{props.children}
</Main>
<Scripts />
</body>
</html>
);
}
export default Document;

View File

@ -0,0 +1,6 @@
import * as React from 'react';
import { Link } from 'ice';
export default function About() {
return <><h2>About</h2><Link to="/">home</Link></>;
}

View File

@ -0,0 +1,7 @@
import React from 'react';
export default () => {
return (
<h3>A page</h3>
);
};

View File

@ -0,0 +1,7 @@
import React from 'react';
export default () => {
return (
<h3>B page</h3>
);
};

View File

@ -0,0 +1,7 @@
import React from 'react';
export default () => {
return (
<div>Index</div>
);
};

View File

@ -0,0 +1,15 @@
import * as React from 'react';
import { Outlet, Link } from 'ice';
export default () => {
return (
<div>
<h2>Dashboard</h2>
<ul>
<li><Link to="/dashboard/a">a</Link></li>
<li><Link to="/dashboard/b">b</Link></li>
</ul>
<Outlet />
</div>
);
};

View File

@ -0,0 +1,13 @@
import React from 'react';
import { useParams, Link } from 'ice';
export default function DetailId() {
const params = useParams();
return (
<div>
<h2>Detail id: {params.id}</h2>
<Link to="/detail">Back to Detail</Link>
</div>
);
}

View File

@ -0,0 +1,14 @@
import React from 'react';
import { Link } from 'ice';
export default function Detail() {
return (
<div>
<h2>Detail</h2>
<ul>
<li><Link to="/detail/join">join</Link></li>
<li><Link to="/detail/dashboard">dashboard</Link></li>
</ul>
</div>
);
}

View File

@ -0,0 +1,15 @@
import * as React from 'react';
import { Link } from 'ice';
export default function Home() {
return (
<>
<h2>Home</h2>
<ul>
<li><Link to="/about-me">about</Link></li>
<li><Link to="/detail">detail</Link></li>
<li><Link to="/dashboard">dashboard</Link></li>
</ul>
</>
);
}

View File

@ -0,0 +1,11 @@
import * as React from 'react';
import { Outlet } from 'ice';
export default () => {
return (
<div>
<h1>Layout</h1>
<Outlet />
</div>
);
};

View File

@ -0,0 +1,6 @@
import * as React from 'react';
import { Link } from 'ice';
export default function Products() {
return <><h2>Products Page</h2><Link to="/">home</Link></>;
}

View File

@ -0,0 +1,32 @@
{
"compileOnSave": false,
"buildOnSave": false,
"compilerOptions": {
"baseUrl": ".",
"outDir": "build",
"module": "esnext",
"target": "es6",
"jsx": "react",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"lib": ["es6", "dom"],
"sourceMap": true,
"allowJs": true,
"rootDir": "./",
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": false,
"importHelpers": true,
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": true,
"noUnusedLocals": true,
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"],
"ice": [".ice"]
}
},
"include": ["src", ".ice", "ice.config.*"],
"exclude": ["node_modules", "build", "public"]
}

View File

@ -55,7 +55,7 @@
"semver": "^7.3.5",
"stylelint": "^14.3.0",
"typescript": "^4.5.5",
"vitest": "^0.8.4"
"vitest": "^0.9.2"
},
"packageManager": "pnpm"
}

View File

@ -33,7 +33,7 @@
},
"devDependencies": {
"@ice/types": "^1.0.0",
"build-scripts": "^2.0.0-15",
"build-scripts": "^2.0.0-16",
"webpack": "^5.69.1",
"webpack-dev-server": "^4.7.4"
}

View File

@ -41,7 +41,7 @@ function getEntry(rootDir: string) {
}
return {
runtime: ['react', 'react-dom', '@ice/runtime'],
index: {
main: {
import: [entryFile],
dependOn: 'runtime',
},

View File

@ -24,7 +24,7 @@
"@ice/runtime": "^1.0.0",
"@ice/webpack-config": "^1.0.0",
"address": "^1.1.2",
"build-scripts": "^2.0.0-15",
"build-scripts": "^2.0.0-16",
"chalk": "^4.0.0",
"commander": "^9.0.0",
"consola": "^2.15.3",

View File

@ -32,16 +32,9 @@ async function createService({ rootDir, command, commandArgs }: CreateServiceOpt
const templateDir = path.join(__dirname, '../template/');
const configFile = 'ice.config.(mts|mjs|ts|js|cjs|json)';
const dataCache = new Map<string, string>();
const routesRenderData = generateRoutesInfo(rootDir);
dataCache.set('routes', JSON.stringify(routesRenderData));
const generator = new Generator({
rootDir,
targetDir,
defaultRenderData: {
...routesRenderData,
},
// add default template of ice
templates: [templateDir],
});
@ -49,14 +42,6 @@ async function createService({ rootDir, command, commandArgs }: CreateServiceOpt
const { addWatchEvent, removeWatchEvent } = createWatch({
watchDir: rootDir,
command,
watchEvents: getWatchEvents({
generator,
rootDir,
targetDir,
templateDir,
configFile,
cache: dataCache,
}),
});
const generatorAPI = {
@ -72,6 +57,7 @@ async function createService({ rootDir, command, commandArgs }: CreateServiceOpt
addRenderFile: generator.addRenderFile,
addRenderTemplate: generator.addTemplateFiles,
};
const ctx = new Context<any, ExtendsPluginAPI>({
rootDir,
command,
@ -90,16 +76,32 @@ async function createService({ rootDir, command, commandArgs }: CreateServiceOpt
},
});
await ctx.resolveConfig();
const { userConfig: { routes: routesConfig } } = ctx;
const routesRenderData = generateRoutesInfo(rootDir, routesConfig);
generator.modifyRenderData((renderData) => ({
...renderData,
...routesRenderData,
}));
dataCache.set('routes', JSON.stringify(routesRenderData.routeManifest));
const runtimeModules = getRuntimeModules(ctx.getAllPlugin());
generator.modifyRenderData((renderData) => ({
...renderData,
runtimeModules,
}));
await ctx.setup();
// render template before webpack compile
const renderStart = new Date().getTime();
generator.render();
addWatchEvent(
...getWatchEvents({ generator, targetDir, templateDir, cache: dataCache, ctx }),
);
consola.debug('template render cost:', new Date().getTime() - renderStart);
// define runtime env before get webpack config
defineRuntimeEnv();
const compileIncludes = runtimeModules.map(({ name }) => `${name}/runtime`);

View File

@ -1,32 +1,34 @@
import * as path from 'path';
import consola from 'consola';
import type { WatchEvent } from '@ice/types/esm/plugin.js';
import type { Context } from 'build-scripts';
import type { Config } from '@ice/types';
import { generateRoutesInfo } from './routes.js';
import type Generator from './service/runtimeGenerator';
interface Options {
rootDir: string;
targetDir: string;
templateDir: string;
configFile: string;
generator: Generator;
cache: Map<string, string>;
ctx: Context<Config>;
}
const getWatchEvents = (options: Options): WatchEvent[] => {
const { rootDir, generator, targetDir, templateDir, configFile, cache } = options;
const { generator, targetDir, templateDir, cache, ctx } = options;
const { userConfig: { routes: routesConfig }, configFile, rootDir } = ctx;
const watchRoutes: WatchEvent = [
/src\/pages\/?[\w*-:.$]+$/,
(eventName: string) => {
if (eventName === 'add' || eventName === 'unlink') {
const routesRenderData = generateRoutesInfo(rootDir);
const routesRenderData = generateRoutesInfo(rootDir, routesConfig);
const stringifiedData = JSON.stringify(routesRenderData);
if (cache.get('routes') !== stringifiedData) {
cache.set('routes', stringifiedData);
consola.debug('[event]', `routes data regenerated: ${stringifiedData}`);
generator.renderFile(
path.join(templateDir, 'routes.ts.ejs'),
path.join(rootDir, targetDir, 'route.ts'),
path.join(rootDir, targetDir, 'routes.ts'),
routesRenderData,
);
generator.renderFile(
@ -38,6 +40,7 @@ const getWatchEvents = (options: Options): WatchEvent[] => {
}
},
];
const watchGlobalStyle: WatchEvent = [
/src\/global.(scss|less|css)/,
(event: string, filePath: string) => {
@ -49,7 +52,7 @@ const getWatchEvents = (options: Options): WatchEvent[] => {
];
const watchConfigFile: WatchEvent = [
new RegExp(configFile),
new RegExp((typeof configFile === 'string' ? [configFile] : configFile).join('|')),
(event: string, filePath: string) => {
if (event === 'change') {
consola.warn(`Found a change in ${path.basename(filePath)}. Restart the dev server to see the changes in effect.`);

View File

@ -112,6 +112,10 @@ const userConfig = [
}
},
},
{
name: 'routes',
validation: 'object',
},
];
const cliOptions = [

View File

@ -1,5 +1,5 @@
import * as fs from 'fs';
import * as path from 'path';
import fse from 'fs-extra';
import type { RouteItem } from '@ice/runtime';
interface Options {
@ -20,7 +20,7 @@ export default async function generateHTML(options: Options) {
} = options;
const serverEntry = await import(entry);
const routes = JSON.parse(fs.readFileSync(routeManifest, 'utf8'));
const routes = JSON.parse(fse.readFileSync(routeManifest, 'utf8'));
const paths = getPaths(routes);
for (let i = 0, n = paths.length; i < n; i++) {
@ -40,7 +40,9 @@ export default async function generateHTML(options: Options) {
}
const fileName = routePath === '/' ? 'index.html' : `${routePath}.html`;
fs.writeFileSync(path.join(outDir, fileName), html);
const contentPath = path.join(outDir, fileName);
await fse.ensureFile(contentPath);
await fse.writeFile(contentPath, html);
}
}
@ -49,14 +51,14 @@ export default async function generateHTML(options: Options) {
* @param routes
* @returns
*/
function getPaths(routes: RouteItem[]): string[] {
function getPaths(routes: RouteItem[], parentPath = ''): string[] {
let pathList = [];
routes.forEach(route => {
if (route.children) {
pathList = pathList.concat(getPaths(route.children));
pathList = pathList.concat(getPaths(route.children, route.path));
} else {
pathList.push(route.path || '/');
pathList.push(path.join('/', parentPath, route.path || ''));
}
});

View File

@ -1,9 +1,10 @@
import * as path from 'path';
import { formatNestedRouteManifest, generateRouteManifest } from '@ice/route-manifest';
import type { NestedRouteManifest } from '@ice/route-manifest';
import type { UserConfig } from '@ice/types';
export function generateRoutesInfo(rootDir: string) {
const routeManifest = generateRouteManifest(rootDir);
export function generateRoutesInfo(rootDir: string, routesConfig: UserConfig['routes'] = {}) {
const routeManifest = generateRouteManifest(rootDir, routesConfig.ignoreFiles, routesConfig.defineRoutes);
const routes = formatNestedRouteManifest(routeManifest);
const str = generateNestRoutesStr(routes);
@ -23,7 +24,7 @@ function generateNestRoutesStr(nestRouteManifest: NestedRouteManifest[]) {
let str = `{
path: '${routePath || ''}',
load: () => import(/* webpackChunkName: "${componentName}" */ '@/${componentFile}'),
load: () => import(/* webpackChunkName: "${componentName}" */ '@/pages/${componentFile}'),
componentName: '${componentName}',
index: ${index},
id: '${id}',

View File

@ -32,7 +32,7 @@ const RENDER_WAIT = 150;
interface Options {
rootDir: string;
targetDir: string;
defaultRenderData: RenderData;
defaultRenderData?: RenderData;
templates?: (string | TemplateOptions)[];
}
@ -106,7 +106,7 @@ export default class Generator {
private contentTypes: string[];
public constructor(options: Options) {
const { rootDir, targetDir, defaultRenderData, templates } = options;
const { rootDir, targetDir, defaultRenderData = {}, templates } = options;
this.rootDir = rootDir;
this.targetDir = targetDir;
this.renderData = defaultRenderData;

View File

@ -29,8 +29,8 @@ function createWatch(options: {
return {
watcher,
addWatchEvent: ([pattern, action, name]: WatchEvent) => {
watchEvents.push([pattern, action, name]);
addWatchEvent: (...args: WatchEvent[]) => {
watchEvents.push(...args);
},
removeWatchEvent: (name: string) => {
const eventIndex = watchEvents.findIndex(([,,watchName]) => watchName === name);

View File

@ -3,6 +3,8 @@ import {
useAppContext,
Link,
Outlet,
useParams,
useSearchParams,
Meta,
Title,
Links,
@ -16,6 +18,8 @@ export {
useAppContext,
Link,
Outlet,
useParams,
useSearchParams,
Meta,
Title,
Links,

View File

@ -5,9 +5,10 @@ import minimatch from 'minimatch';
import { createRouteId, defineRoutes } from './routes.js';
import type { RouteManifest, DefineRouteFunction, NestedRouteManifest } from './routes.js';
export {
export type {
RouteManifest,
NestedRouteManifest,
DefineRouteFunction,
};
const validRouteChar = ['-', '\\w', '/', ':', '*'];
@ -26,14 +27,18 @@ export function isRouteModuleFile(filename: string): boolean {
return routeModuleExts.includes(path.extname(filename));
}
export function generateRouteManifest(rootDir: string) {
export function generateRouteManifest(
rootDir: string,
ignoreFiles: string[] = [],
defineExtraRoutes?: (defineRoute: DefineRouteFunction) => void,
) {
const srcDir = path.join(rootDir, 'src');
const routeManifest: RouteManifest = {};
// 2. find routes in `src/pages` directory
if (fs.existsSync(path.resolve(srcDir, 'pages'))) {
const conventionalRoutes = defineConventionalRoutes(
rootDir,
[], // TODO: add ignoredFilePatterns defined in ice.config.js
ignoreFiles,
);
for (const key of Object.keys(conventionalRoutes)) {
@ -44,7 +49,17 @@ export function generateRouteManifest(rootDir: string) {
};
}
}
// 3. add extra routes from user config
if (defineExtraRoutes) {
const extraRoutes = defineRoutes(defineExtraRoutes);
for (const key of Object.keys(extraRoutes)) {
const route = extraRoutes[key];
routeManifest[route.id] = {
...route,
parentId: route.parentId || undefined,
};
}
}
return routeManifest;
}
@ -66,7 +81,6 @@ function defineConventionalRoutes(
ignoredFilePatterns?: string[],
): RouteManifest {
const files: { [routeId: string]: string } = {};
// 1. find all route components in src/pages
visitFiles(
path.join(rootDir, 'src', 'pages'),
@ -78,10 +92,9 @@ function defineConventionalRoutes(
return;
}
const filePath = path.join('pages', file);
if (isRouteModuleFile(file)) {
let routeId = createRouteId(filePath);
files[routeId] = filePath;
let routeId = createRouteId(file);
files[routeId] = file;
return;
}
},
@ -102,24 +115,25 @@ function defineConventionalRoutes(
});
for (let routeId of childRouteIds) {
const parentRoutePath = removeLastLayoutStrFromId(parentId) || '';
const routePath: string | undefined = createRoutePath(
routeId.slice((removeLayoutStrFromId(parentId) || 'pages').length),
// parentRoutePath = 'home', routeId = 'home/me', the new routeId is 'me'
// in order to escape the child route path is absolute path
routeId.slice(parentRoutePath.length + (parentRoutePath ? 1 : 0)),
);
const routeFilePath = path.join('src', 'pages', files[routeId]);
if (RegExp(`[^${validRouteChar.join(',')}]`).test(routePath)) {
throw new Error(`invalid character in '${routeId}'. Only support char: ${validRouteChar.join(', ')}`);
throw new Error(`invalid character in '${routeFilePath}'. Only support char: ${validRouteChar.join(', ')}`);
}
const isIndexRoute = routeId.endsWith('/index');
let fullPath = createRoutePath(routeId.slice('pages'.length + 1));
let uniqueRouteId = (fullPath || '') + (isIndexRoute ? '?index' : '');
const isIndexRoute = routeId === 'index' || routeId.endsWith('/index');
const fullPath = createRoutePath(routeId);
const uniqueRouteId = (fullPath || '') + (isIndexRoute ? '?index' : '');
if (uniqueRouteId) {
if (uniqueRoutes.has(uniqueRouteId)) {
throw new Error(
`Path ${JSON.stringify(fullPath)} defined by route ${JSON.stringify(
routeId,
)} conflicts with route ${JSON.stringify(
uniqueRoutes.get(uniqueRouteId),
)}`,
`Path ${JSON.stringify(fullPath)} defined by route ${JSON.stringify(routeFilePath)}
conflicts with route ${JSON.stringify(uniqueRoutes.get(uniqueRouteId))}`,
);
} else {
uniqueRoutes.set(uniqueRouteId, routeId);
@ -155,7 +169,7 @@ export function createRoutePath(routeId: string): string | undefined {
let result = '';
let rawSegmentBuffer = '';
const partialRouteId = removeLayoutStrFromId(routeId);
const partialRouteId = removeLastLayoutStrFromId(routeId);
for (let i = 0; i < partialRouteId.length; i++) {
const char = partialRouteId.charAt(i);
@ -196,7 +210,7 @@ function findParentRouteId(
return routeIds.find((id) => {
// childRouteId is `pages/about` and id is `pages/layout` will match
// childRouteId is `pages/about/index` and id is `pages/about/layout` will match
return childRouteId !== id && id.endsWith('layout') && childRouteId.startsWith(`${id.slice(0, id.length - '/layout'.length)}`);
return childRouteId !== id && id.endsWith('layout') && childRouteId.startsWith(`${id.slice(0, id.length - 'layout'.length)}`);
});
}
@ -224,9 +238,12 @@ function visitFiles(
/**
* remove `/layout` str if the routeId has it
*
* /About/layout -> /About
* /About/layout/index -> /About/layout/index
* 'layout' -> ''
* 'About/layout' -> 'About'
* 'About/layout/index' -> 'About/layout/index'
*/
function removeLayoutStrFromId(id?: string) {
return id?.endsWith('/layout') ? id.slice(0, id.length - '/layout'.length) : id;
function removeLastLayoutStrFromId(id?: string) {
const layoutStrs = ['/layout', 'layout'];
const currentLayoutStr = layoutStrs.find(layoutStr => id?.endsWith(layoutStr));
return currentLayoutStr ? id.slice(0, id.length - currentLayoutStr.length) : id;
}

View File

@ -100,9 +100,10 @@ export function defineRoutes(
// route(path, file, options)
options = optionsOrChildren || {};
}
const id = createRouteId(file);
const route: ConfigRoute = {
path: path || undefined,
path,
index: options.index ? true : undefined,
id,
parentId:
@ -144,6 +145,6 @@ function stripFileExtension(file: string) {
function createComponentName(id: string) {
return id.replace('.', '/') // 'pages/home.news' -> pages/home/news
.split('/')
.map((item: string) => item[0].toUpperCase() + item.slice(1, item.length))
.join('');
.map((item: string) => item.toLowerCase())
.join('-');
}

View File

@ -3,70 +3,33 @@
exports[`generateRouteManifest function > layout-routes 1`] = `
[
{
"componentName": "PagesBlogIndex",
"file": "pages/blog/index.tsx",
"id": "pages/blog/index",
"componentName": "blog-index",
"file": "blog/index.tsx",
"id": "blog/index",
"index": true,
"parentId": undefined,
"path": "/blog",
"path": "blog",
},
{
"componentName": "PagesBlog$id",
"file": "pages/blog/$id.tsx",
"id": "pages/blog/$id",
"componentName": "blog-$id",
"file": "blog/$id.tsx",
"id": "blog/$id",
"index": undefined,
"parentId": undefined,
"path": "/blog/:id",
"path": "blog/:id",
},
{
"componentName": "PagesAbout",
"file": "pages/about.tsx",
"id": "pages/about",
"componentName": "about",
"file": "about.tsx",
"id": "about",
"index": undefined,
"parentId": undefined,
"path": "/about",
"path": "about",
},
{
"componentName": "PagesIndex",
"file": "pages/index.tsx",
"id": "pages/index",
"index": true,
"parentId": undefined,
"path": undefined,
},
]
`;
exports[`generateRouteManifest function layout-routes 1`] = `
Array [
Object {
"componentName": "PagesBlogIndex",
"file": "pages/blog/index.tsx",
"id": "pages/blog/index",
"index": true,
"parentId": undefined,
"path": "/blog",
},
Object {
"componentName": "PagesBlog$id",
"file": "pages/blog/$id.tsx",
"id": "pages/blog/$id",
"index": undefined,
"parentId": undefined,
"path": "/blog/:id",
},
Object {
"componentName": "PagesAbout",
"file": "pages/about.tsx",
"id": "pages/about",
"index": undefined,
"parentId": undefined,
"path": "/about",
},
Object {
"componentName": "PagesIndex",
"file": "pages/index.tsx",
"id": "pages/index",
"componentName": "index",
"file": "index.tsx",
"id": "index",
"index": true,
"parentId": undefined,
"path": undefined,

View File

@ -2,42 +2,87 @@
exports[`generateRouteManifest function > basic-routes 1`] = `
{
"pages/About/index": {
"componentName": "PagesAboutIndex",
"file": "pages/About/index.tsx",
"id": "pages/About/index",
"About/index": {
"componentName": "about-index",
"file": "About/index.tsx",
"id": "About/index",
"index": true,
"parentId": "pages/layout",
"path": "/About",
"parentId": "layout",
"path": "About",
},
"pages/About/me/index": {
"componentName": "PagesAboutMeIndex",
"file": "pages/About/me/index.tsx",
"id": "pages/About/me/index",
"About/me/index": {
"componentName": "about-me-index",
"file": "About/me/index.tsx",
"id": "About/me/index",
"index": true,
"parentId": "pages/layout",
"path": "/About/me",
"parentId": "layout",
"path": "About/me",
},
"pages/home": {
"componentName": "PagesHome",
"file": "pages/home.tsx",
"id": "pages/home",
"home": {
"componentName": "home",
"file": "home.tsx",
"id": "home",
"index": undefined,
"parentId": "pages/layout",
"path": "/home",
"parentId": "layout",
"path": "home",
},
"pages/index": {
"componentName": "PagesIndex",
"file": "pages/index.tsx",
"id": "pages/index",
"index": {
"componentName": "index",
"file": "index.tsx",
"id": "index",
"index": true,
"parentId": "pages/layout",
"parentId": "layout",
"path": undefined,
},
"pages/layout": {
"componentName": "PagesLayout",
"file": "pages/layout.tsx",
"id": "pages/layout",
"layout": {
"componentName": "layout",
"file": "layout.tsx",
"id": "layout",
"index": undefined,
"parentId": undefined,
"path": undefined,
},
}
`;
exports[`generateRouteManifest function > define-extra-routes 1`] = `
{
"About/index": {
"componentName": "about-index",
"file": "About/index.tsx",
"id": "About/index",
"index": undefined,
"parentId": undefined,
"path": "/about-me",
},
"About/me/index": {
"componentName": "about-me-index",
"file": "About/me/index.tsx",
"id": "About/me/index",
"index": true,
"parentId": "layout",
"path": "About/me",
},
"home": {
"componentName": "home",
"file": "home.tsx",
"id": "home",
"index": undefined,
"parentId": "layout",
"path": "home",
},
"index": {
"componentName": "index",
"file": "index.tsx",
"id": "index",
"index": true,
"parentId": "layout",
"path": undefined,
},
"layout": {
"componentName": "layout",
"file": "layout.tsx",
"id": "layout",
"index": undefined,
"parentId": undefined,
"path": undefined,
@ -47,18 +92,18 @@ exports[`generateRouteManifest function > basic-routes 1`] = `
exports[`generateRouteManifest function > doc-delimeters-routes 1`] = `
{
"pages/home.news": {
"componentName": "PagesHomeNews",
"file": "pages/home.news.tsx",
"id": "pages/home.news",
"home.news": {
"componentName": "home-news",
"file": "home.news.tsx",
"id": "home.news",
"index": undefined,
"parentId": "pages/layout",
"path": "/home/news",
"parentId": "layout",
"path": "home/news",
},
"pages/layout": {
"componentName": "PagesLayout",
"file": "pages/layout.tsx",
"id": "pages/layout",
"layout": {
"componentName": "layout",
"file": "layout.tsx",
"id": "layout",
"index": undefined,
"parentId": undefined,
"path": undefined,
@ -68,34 +113,34 @@ exports[`generateRouteManifest function > doc-delimeters-routes 1`] = `
exports[`generateRouteManifest function > dynamic-routes 1`] = `
{
"pages/about": {
"componentName": "PagesAbout",
"file": "pages/about.tsx",
"id": "pages/about",
"about": {
"componentName": "about",
"file": "about.tsx",
"id": "about",
"index": undefined,
"parentId": undefined,
"path": "/about",
"path": "about",
},
"pages/blog/$id": {
"componentName": "PagesBlog$id",
"file": "pages/blog/$id.tsx",
"id": "pages/blog/$id",
"blog/$id": {
"componentName": "blog-$id",
"file": "blog/$id.tsx",
"id": "blog/$id",
"index": undefined,
"parentId": undefined,
"path": "/blog/:id",
"path": "blog/:id",
},
"pages/blog/index": {
"componentName": "PagesBlogIndex",
"file": "pages/blog/index.tsx",
"id": "pages/blog/index",
"blog/index": {
"componentName": "blog-index",
"file": "blog/index.tsx",
"id": "blog/index",
"index": true,
"parentId": undefined,
"path": "/blog",
"path": "blog",
},
"pages/index": {
"componentName": "PagesIndex",
"file": "pages/index.tsx",
"id": "pages/index",
"index": {
"componentName": "index",
"file": "index.tsx",
"id": "index",
"index": true,
"parentId": undefined,
"path": undefined,
@ -103,76 +148,113 @@ exports[`generateRouteManifest function > dynamic-routes 1`] = `
}
`;
exports[`generateRouteManifest function > ignore-routes 1`] = `
{
"About/me/index": {
"componentName": "about-me-index",
"file": "About/me/index.tsx",
"id": "About/me/index",
"index": true,
"parentId": "layout",
"path": "About/me",
},
"home": {
"componentName": "home",
"file": "home.tsx",
"id": "home",
"index": undefined,
"parentId": "layout",
"path": "home",
},
"index": {
"componentName": "index",
"file": "index.tsx",
"id": "index",
"index": true,
"parentId": "layout",
"path": undefined,
},
"layout": {
"componentName": "layout",
"file": "layout.tsx",
"id": "layout",
"index": undefined,
"parentId": undefined,
"path": undefined,
},
}
`;
exports[`generateRouteManifest function > layout-routes 1`] = `
{
"pages/about": {
"componentName": "PagesAbout",
"file": "pages/about.tsx",
"id": "pages/about",
"about": {
"componentName": "about",
"file": "about.tsx",
"id": "about",
"index": undefined,
"parentId": "pages/layout",
"path": "/about",
"parentId": "layout",
"path": "about",
},
"pages/blog/$id": {
"componentName": "PagesBlog$id",
"file": "pages/blog/$id.tsx",
"id": "pages/blog/$id",
"blog/$id": {
"componentName": "blog-$id",
"file": "blog/$id.tsx",
"id": "blog/$id",
"index": undefined,
"parentId": "pages/blog/layout",
"path": "/:id",
"parentId": "blog/layout",
"path": ":id",
},
"pages/blog/index": {
"componentName": "PagesBlogIndex",
"file": "pages/blog/index.tsx",
"id": "pages/blog/index",
"blog/index": {
"componentName": "blog-index",
"file": "blog/index.tsx",
"id": "blog/index",
"index": true,
"parentId": "pages/blog/layout",
"parentId": "blog/layout",
"path": undefined,
},
"pages/blog/layout": {
"componentName": "PagesBlogLayout",
"file": "pages/blog/layout.tsx",
"id": "pages/blog/layout",
"blog/layout": {
"componentName": "blog-layout",
"file": "blog/layout.tsx",
"id": "blog/layout",
"index": undefined,
"parentId": "pages/layout",
"path": "/blog",
"parentId": "layout",
"path": "blog",
},
"pages/home/index": {
"componentName": "PagesHomeIndex",
"file": "pages/home/index.tsx",
"id": "pages/home/index",
"home/index": {
"componentName": "home-index",
"file": "home/index.tsx",
"id": "home/index",
"index": true,
"parentId": "pages/home/layout",
"parentId": "home/layout",
"path": undefined,
},
"pages/home/layout": {
"componentName": "PagesHomeLayout",
"file": "pages/home/layout.tsx",
"id": "pages/home/layout",
"home/layout": {
"componentName": "home-layout",
"file": "home/layout.tsx",
"id": "home/layout",
"index": undefined,
"parentId": "pages/layout",
"path": "/home",
"parentId": "layout",
"path": "home",
},
"pages/home/layout/index": {
"componentName": "PagesHomeLayoutIndex",
"file": "pages/home/layout/index.tsx",
"id": "pages/home/layout/index",
"home/layout/index": {
"componentName": "home-layout-index",
"file": "home/layout/index.tsx",
"id": "home/layout/index",
"index": true,
"parentId": "pages/home/layout",
"path": "/layout",
"parentId": "home/layout",
"path": "layout",
},
"pages/index": {
"componentName": "PagesIndex",
"file": "pages/index.tsx",
"id": "pages/index",
"index": {
"componentName": "index",
"file": "index.tsx",
"id": "index",
"index": true,
"parentId": "pages/layout",
"parentId": "layout",
"path": undefined,
},
"pages/layout": {
"componentName": "PagesLayout",
"file": "pages/layout.tsx",
"id": "pages/layout",
"layout": {
"componentName": "layout",
"file": "layout.tsx",
"id": "layout",
"index": undefined,
"parentId": undefined,
"path": undefined,
@ -182,235 +264,26 @@ exports[`generateRouteManifest function > layout-routes 1`] = `
exports[`generateRouteManifest function > splat-routes 1`] = `
{
"pages/$": {
"componentName": "Pages$",
"file": "pages/$.tsx",
"id": "pages/$",
"$": {
"componentName": "$",
"file": "$.tsx",
"id": "$",
"index": undefined,
"parentId": "pages/layout",
"path": "/*",
"parentId": "layout",
"path": "*",
},
"pages/home": {
"componentName": "PagesHome",
"file": "pages/home.tsx",
"id": "pages/home",
"home": {
"componentName": "home",
"file": "home.tsx",
"id": "home",
"index": undefined,
"parentId": "pages/layout",
"path": "/home",
"parentId": "layout",
"path": "home",
},
"pages/layout": {
"componentName": "PagesLayout",
"file": "pages/layout.tsx",
"id": "pages/layout",
"index": undefined,
"parentId": undefined,
"path": undefined,
},
}
`;
exports[`generateRouteManifest function basic-routes 1`] = `
Object {
"pages/About/index": Object {
"componentName": "PagesAboutIndex",
"file": "pages/About/index.tsx",
"id": "pages/About/index",
"index": true,
"parentId": "pages/layout",
"path": "/About",
},
"pages/About/me/index": Object {
"componentName": "PagesAboutMeIndex",
"file": "pages/About/me/index.tsx",
"id": "pages/About/me/index",
"index": true,
"parentId": "pages/layout",
"path": "/About/me",
},
"pages/home": Object {
"componentName": "PagesHome",
"file": "pages/home.tsx",
"id": "pages/home",
"index": undefined,
"parentId": "pages/layout",
"path": "/home",
},
"pages/index": Object {
"componentName": "PagesIndex",
"file": "pages/index.tsx",
"id": "pages/index",
"index": true,
"parentId": "pages/layout",
"path": undefined,
},
"pages/layout": Object {
"componentName": "PagesLayout",
"file": "pages/layout.tsx",
"id": "pages/layout",
"index": undefined,
"parentId": undefined,
"path": undefined,
},
}
`;
exports[`generateRouteManifest function doc-delimeters-routes 1`] = `
Object {
"pages/home.news": Object {
"componentName": "PagesHomeNews",
"file": "pages/home.news.tsx",
"id": "pages/home.news",
"index": undefined,
"parentId": "pages/layout",
"path": "/home/news",
},
"pages/layout": Object {
"componentName": "PagesLayout",
"file": "pages/layout.tsx",
"id": "pages/layout",
"index": undefined,
"parentId": undefined,
"path": undefined,
},
}
`;
exports[`generateRouteManifest function dynamic-routes 1`] = `
Object {
"pages/about": Object {
"componentName": "PagesAbout",
"file": "pages/about.tsx",
"id": "pages/about",
"index": undefined,
"parentId": undefined,
"path": "/about",
},
"pages/blog/$id": Object {
"componentName": "PagesBlog$id",
"file": "pages/blog/$id.tsx",
"id": "pages/blog/$id",
"index": undefined,
"parentId": undefined,
"path": "/blog/:id",
},
"pages/blog/index": Object {
"componentName": "PagesBlogIndex",
"file": "pages/blog/index.tsx",
"id": "pages/blog/index",
"index": true,
"parentId": undefined,
"path": "/blog",
},
"pages/index": Object {
"componentName": "PagesIndex",
"file": "pages/index.tsx",
"id": "pages/index",
"index": true,
"parentId": undefined,
"path": undefined,
},
}
`;
exports[`generateRouteManifest function layout-routes 1`] = `
Object {
"pages/about": Object {
"componentName": "PagesAbout",
"file": "pages/about.tsx",
"id": "pages/about",
"index": undefined,
"parentId": "pages/layout",
"path": "/about",
},
"pages/blog/$id": Object {
"componentName": "PagesBlog$id",
"file": "pages/blog/$id.tsx",
"id": "pages/blog/$id",
"index": undefined,
"parentId": "pages/blog/layout",
"path": "/:id",
},
"pages/blog/index": Object {
"componentName": "PagesBlogIndex",
"file": "pages/blog/index.tsx",
"id": "pages/blog/index",
"index": true,
"parentId": "pages/blog/layout",
"path": undefined,
},
"pages/blog/layout": Object {
"componentName": "PagesBlogLayout",
"file": "pages/blog/layout.tsx",
"id": "pages/blog/layout",
"index": undefined,
"parentId": "pages/layout",
"path": "/blog",
},
"pages/home/index": Object {
"componentName": "PagesHomeIndex",
"file": "pages/home/index.tsx",
"id": "pages/home/index",
"index": true,
"parentId": "pages/home/layout",
"path": undefined,
},
"pages/home/layout": Object {
"componentName": "PagesHomeLayout",
"file": "pages/home/layout.tsx",
"id": "pages/home/layout",
"index": undefined,
"parentId": "pages/layout",
"path": "/home",
},
"pages/home/layout/index": Object {
"componentName": "PagesHomeLayoutIndex",
"file": "pages/home/layout/index.tsx",
"id": "pages/home/layout/index",
"index": true,
"parentId": "pages/home/layout",
"path": "/layout",
},
"pages/index": Object {
"componentName": "PagesIndex",
"file": "pages/index.tsx",
"id": "pages/index",
"index": true,
"parentId": "pages/layout",
"path": undefined,
},
"pages/layout": Object {
"componentName": "PagesLayout",
"file": "pages/layout.tsx",
"id": "pages/layout",
"index": undefined,
"parentId": undefined,
"path": undefined,
},
}
`;
exports[`generateRouteManifest function splat-routes 1`] = `
Object {
"pages/$": Object {
"componentName": "Pages$",
"file": "pages/$.tsx",
"id": "pages/$",
"index": undefined,
"parentId": "pages/layout",
"path": "/*",
},
"pages/home": Object {
"componentName": "PagesHome",
"file": "pages/home.tsx",
"id": "pages/home",
"index": undefined,
"parentId": "pages/layout",
"path": "/home",
},
"pages/layout": Object {
"componentName": "PagesLayout",
"file": "pages/layout.tsx",
"id": "pages/layout",
"layout": {
"componentName": "layout",
"file": "layout.tsx",
"id": "layout",
"index": undefined,
"parentId": undefined,
"path": undefined,

View File

@ -33,6 +33,22 @@ describe('generateRouteManifest function', () => {
});
test('invalid-routes', () => {
expect(() => generateRouteManifest(path.join(fixturesDir, 'invalid-routes'))).toThrow(`invalid character in 'pages/[a.pdf]'. Only support char: -, \\w, /`);
expect(() => generateRouteManifest(path.join(fixturesDir, 'invalid-routes'))).toThrow(`invalid character in 'src/pages/[a.pdf].tsx'. Only support char: -, \\w, /`);
});
test('ignore-routes', () => {
const routeManifest = generateRouteManifest(path.join(fixturesDir, 'basic-routes'), ['About/index.tsx']);
expect(routeManifest).toMatchSnapshot();
});
test('define-extra-routes', () => {
const routeManifest = generateRouteManifest(
path.join(fixturesDir, 'basic-routes'),
['About/index.tsx'],
(defineRoute) => {
defineRoute('/about-me', 'About/index.tsx');
}
);
expect(routeManifest).toMatchSnapshot();
});
});

View File

@ -1,6 +1,8 @@
import {
Link,
Outlet,
useParams,
useSearchParams,
} from 'react-router-dom';
import Runtime from './runtime.js';
import App from './App.js';
@ -14,7 +16,7 @@ import {
Scripts,
Main,
} from './Document.js';
import {
import type {
RuntimePlugin,
AppContext,
AppConfig,
@ -32,15 +34,20 @@ export {
runServerApp,
renderDocument,
useAppContext,
Link,
Outlet,
Meta,
Title,
Links,
Scripts,
Main,
defineAppConfig,
// types
// react-router-dom API
Link,
Outlet,
useParams,
useSearchParams,
};
export type {
RuntimePlugin,
AppContext,
AppConfig,

View File

@ -107,8 +107,8 @@ function BrowserEntry({ history, appContext, Document, ...rest }: BrowserEntryPr
useLayoutEffect(() => {
history.listen(({ action, location }) => {
const matches = matchRoutes(routes, location);
if (!matches) {
throw new Error(`Routes not found in location ${location}.`);
if (!matches.length) {
throw new Error(`Routes not found in location ${location.pathname}.`);
}
loadNextPage(matches, (pageData) => {

View File

@ -26,7 +26,7 @@
"bugs": "https://github.com/ice-lab/ice-next/issues",
"homepage": "https://next.ice.work",
"devDependencies": {
"build-scripts": "^2.0.0-15",
"build-scripts": "^2.0.0-16",
"esbuild": "^0.14.23",
"@ice/runtime": "^1.0.0",
"@ice/route-manifest": "^1.0.0",

View File

@ -1,3 +1,4 @@
import type { DefineRouteFunction } from '@ice/route-manifest';
import type { IPluginList } from 'build-scripts';
import type { Config, ModifyWebpackConfig } from './config';
@ -12,5 +13,9 @@ export interface UserConfig {
proxy?: Config['proxy'];
filename?: string;
webpack?: ModifyWebpackConfig;
routes?: {
ignoreFiles?: string[];
defineRoutes?: (defineRoute: DefineRouteFunction) => void;
};
plugins?: IPluginList;
}

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,6 @@ describe(`build ${example}`, () => {
test('open /', async () => {
await buildFixture(example);
const res = await setupBrowser({ example });
page = res.page;
browser = res.browser;

View File

@ -0,0 +1,107 @@
import { expect, test, describe, afterAll } from 'vitest';
import { buildFixture, setupBrowser } from '../utils/build';
import { startFixture, setupStartBrowser } from '../utils/start';
import { Page } from '../utils/browser';
const example = 'routes-generate';
describe(`build ${example}`, () => {
let page: Page = null;
let browser = null;
test('open /', async () => {
await buildFixture(example);
const res = await setupBrowser({ example });
page = res.page;
browser = res.browser;
expect(await page.$$text('h1')).toStrictEqual(['Layout']);
expect(await page.$$text('h2')).toStrictEqual(['Home']);
}, 120000);
test('define extra routes', async () => {
let res = await setupBrowser({ example, defaultHtml: 'about-me.html' });
page = res.page;
browser = res.browser;
expect(await page.$$text('h1')).toStrictEqual([]);
expect(await page.$$text('h2')).toStrictEqual(['About']);
res = await setupBrowser({ example, defaultHtml: 'product.html' });
page = res.page;
browser = res.browser;
expect(await page.$$text('h1')).toStrictEqual(['Layout']);
expect(await page.$$text('h2')).toStrictEqual(['Products Page']);
});
test('page layout', async () => {
let res = await setupBrowser({ example, defaultHtml: 'dashboard/a.html' });
page = res.page;
browser = res.browser;
expect(await page.$$text('h1')).toStrictEqual(['Layout']);
expect(await page.$$text('h2')).toStrictEqual(['Dashboard']);
expect(await page.$$text('h3')).toStrictEqual(['A page']);
res = await setupBrowser({ example, defaultHtml: 'dashboard/b.html' });
page = res.page;
browser = res.browser;
expect(await page.$$text('h1')).toStrictEqual(['Layout']);
expect(await page.$$text('h2')).toStrictEqual(['Dashboard']);
expect(await page.$$text('h3')).toStrictEqual(['B page']);
});
// TODO: dynamic-routes test
test.todo('dynamic routes', async () => {});
afterAll(async () => {
await browser.close();
});
});
describe(`start ${example}`, () => {
let page: Page = null;
let browser = null;
test('setup devServer', async () => {
const { devServer, port } = await startFixture(example);
const res = await setupStartBrowser({ server: devServer, port });
page = res.page;
browser = res.browser;
expect(await page.$$text('h1')).toStrictEqual(['Layout']);
expect(await page.$$text('h2')).toStrictEqual(['Home']);
}, 120000);
test('define extra routes', async () => {
await page.push('about-me');
expect(await page.$$text('h1')).toStrictEqual([]);
expect(await page.$$text('h2')).toStrictEqual(['About']);
await page.push('product');
expect(await page.$$text('h1')).toStrictEqual(['Layout']);
expect(await page.$$text('h2')).toStrictEqual(['Products Page']);
});
test('page layout', async () => {
await page.push('dashboard/a');
expect(await page.$$text('h1')).toStrictEqual(['Layout']);
expect(await page.$$text('h2')).toStrictEqual(['Dashboard']);
expect(await page.$$text('h3')).toStrictEqual(['A page']);
await page.push('dashboard/b');
expect(await page.$$text('h1')).toStrictEqual(['Layout']);
expect(await page.$$text('h2')).toStrictEqual(['Dashboard']);
expect(await page.$$text('h3')).toStrictEqual(['B page']);
});
test('dynamic routes layout', async () => {
await page.push('detail/a');
expect(await page.$$text('h1')).toStrictEqual(['Layout']);
expect(await page.$$text('h2')).toStrictEqual(['Detail id: a']);
await page.push('detail/b');
expect(await page.$$text('h1')).toStrictEqual(['Layout']);
expect(await page.$$text('h2')).toStrictEqual(['Detail id: b']);
});
afterAll(async () => {
await browser.close();
});
});

View File

@ -11,7 +11,8 @@ export default defineConfig({
alias: { ...moduleNameMapper },
},
test: {
// disable threads to avoid `Segmentation fault (core dumped)` error: https://github.com/vitest-dev/vitest/issues/317
// To avoid error `Segmentation fault (core dumped)` in CI environment, disable threads
// ref: https://github.com/vitest-dev/vitest/issues/317
threads: false,
exclude: [
'**/node_modules/**',
@ -19,4 +20,4 @@ export default defineConfig({
'**/tests/fixtures/**',
],
},
});
});