diff --git a/.github/workflows/auto-publisher.yml b/.github/workflows/auto-publisher.yml index f4747527e..9699cbeb4 100644 --- a/.github/workflows/auto-publisher.yml +++ b/.github/workflows/auto-publisher.yml @@ -23,7 +23,7 @@ jobs: - name: Install pnpm run: npm i pnpm -g - run: npm run setup - - run: npm run publish:beta + - run: npm run publish:release env: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }} diff --git a/.gitignore b/.gitignore index d2a492172..49de00503 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ coverage *.swp *.dia~ *.temp.json +*.swc # Packages packages/*/lib/ @@ -38,6 +39,7 @@ packages/*/esm/ # temp folder .ice examples/*/.ice +examples/*/.swc build dist diff --git a/examples/basic-project/ice.config.mts b/examples/basic-project/ice.config.mts index 90c4f9d03..ba3e6d26c 100644 --- a/examples/basic-project/ice.config.mts +++ b/examples/basic-project/ice.config.mts @@ -4,13 +4,16 @@ import auth from '@ice/plugin-auth'; export default defineConfig({ publicPath: '/', + syntaxFeatures: { + exportDefaultFrom: true, + }, define: { HAHA: JSON.stringify(true), 'process.env.HAHA': JSON.stringify(true), }, transform: (code, id) => { if (id.includes('src/pages')) { - console.log('transform page:', id); + // console.log('transform page:', id); } return code; }, diff --git a/examples/basic-project/package.json b/examples/basic-project/package.json index dc1fd2aae..6d047b656 100644 --- a/examples/basic-project/package.json +++ b/examples/basic-project/package.json @@ -13,6 +13,7 @@ "@ice/plugin-auth": "workspace:*", "@ice/plugin-rax-compat": "workspace:*", "@ice/runtime": "workspace:*", + "@uni/env": "^1.1.0", "ahooks": "^3.3.8", "react": "^18.0.0", "react-dom": "^18.0.0" diff --git a/examples/basic-project/src/app.tsx b/examples/basic-project/src/app.tsx index 36c1b44e2..ec77cc76a 100644 --- a/examples/basic-project/src/app.tsx +++ b/examples/basic-project/src/app.tsx @@ -1,5 +1,7 @@ import { defineAppConfig } from 'ice'; import { defineAuthConfig } from '@ice/plugin-auth/esm/types'; +import { isWeb, isNode } from '@uni/env'; +import type { GetAppData } from 'ice'; if (process.env.ICE_CORE_ERROR_BOUNDARY === 'true') { console.error('__REMOVED__'); @@ -10,6 +12,14 @@ console.warn('__WARN__'); console.error('__ERROR__'); console.log('process.env.HAHA', process.env.HAHA); +if (isWeb) { + console.error('__IS_WEB__'); +} + +if (isNode) { + console.error('__IS_NODE__'); +} + export const auth = defineAuthConfig(() => { // fetch auth data return { @@ -24,3 +34,14 @@ export default defineAppConfig({ rootId: 'app', }, }); + +export const getAppData: GetAppData = () => { + return new Promise((resolve) => { + resolve({ + title: 'gogogogo', + auth: { + admin: true, + }, + }); + }); +}; \ No newline at end of file diff --git a/examples/basic-project/src/document.tsx b/examples/basic-project/src/document.tsx index 961b46c16..715f32aa7 100644 --- a/examples/basic-project/src/document.tsx +++ b/examples/basic-project/src/document.tsx @@ -1,6 +1,9 @@ -import { Meta, Title, Links, Main, Scripts } from 'ice'; +import { Meta, Title, Links, Main, Scripts, useAppData } from 'ice'; +import type { AppData } from 'ice'; function Document() { + const appData = useAppData(); + return ( @@ -10,6 +13,11 @@ function Document() { <Links /> + <script + dangerouslySetInnerHTML={{ + __html: `console.log('${appData?.title}')`, + }} + /> </head> <body> <Main /> diff --git a/examples/basic-project/src/pages/about.tsx b/examples/basic-project/src/pages/about.tsx index 0f62a9de0..0c46fcb0f 100644 --- a/examples/basic-project/src/pages/about.tsx +++ b/examples/basic-project/src/pages/about.tsx @@ -1,4 +1,5 @@ import { Link, useData, useConfig } from 'ice'; +import { isWeb } from '@uni/env'; // @ts-expect-error import url from './ice.png'; @@ -18,6 +19,7 @@ export default function About() { <Link to="/">home</Link> <img src={url} height="40" width="40" /> <span className="mark">new</span> + <div>isWeb: { isWeb ? 'true' : 'false' }</div> </> ); } diff --git a/examples/basic-project/src/pages/index.tsx b/examples/basic-project/src/pages/index.tsx index 27271e917..649fc2d12 100644 --- a/examples/basic-project/src/pages/index.tsx +++ b/examples/basic-project/src/pages/index.tsx @@ -1,22 +1,25 @@ import { Suspense, lazy } from 'react'; -import { Link, useData, useConfig } from 'ice'; +import { Link, useData, useAppData, useConfig } from 'ice'; // not recommended but works import { useAppContext } from '@ice/runtime'; import { useRequest } from 'ahooks'; +import type { AppData } from 'ice'; import styles from './index.module.css'; const Bar = lazy(() => import('../components/bar')); export default function Home(props) { - console.log('render Home', props); - const appContext = useAppContext(); - console.log('get AppContext', appContext); - + const appData = useAppData<AppData>(); const data = useData(); const config = useConfig(); - console.log('render Home', 'data', data, 'config', config); + if (typeof window !== 'undefined') { + console.log('render Home', props); + console.log('get AppData', appData); + console.log('get AppContext', appContext); + console.log('render Home', 'data', data, 'config', config); + } const { data: foo } = useRequest(() => fetch('/api/foo').then(res => res.json())); const { data: users } = useRequest(() => fetch('/api/users').then(res => res.json())); diff --git a/examples/hash-router/ice.config.mts b/examples/hash-router/ice.config.mts new file mode 100644 index 000000000..42a0579b6 --- /dev/null +++ b/examples/hash-router/ice.config.mts @@ -0,0 +1,6 @@ +import { defineConfig } from '@ice/app'; + +export default defineConfig({ + ssr: false, + ssg: false, +}); \ No newline at end of file diff --git a/examples/hash-router/package.json b/examples/hash-router/package.json new file mode 100644 index 000000000..ea1f252b9 --- /dev/null +++ b/examples/hash-router/package.json @@ -0,0 +1,19 @@ +{ + "name": "hash-router-demo", + "version": "1.0.0", + "scripts": { + "start": "ice start", + "build": "ice build" + }, + "dependencies": { + "@ice/app": "workspace:*", + "@ice/runtime": "workspace:*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "regenerator-runtime": "^0.13.9" + } +} \ No newline at end of file diff --git a/examples/hash-router/src/app.tsx b/examples/hash-router/src/app.tsx new file mode 100644 index 000000000..7cc183b2a --- /dev/null +++ b/examples/hash-router/src/app.tsx @@ -0,0 +1,7 @@ +import { defineAppConfig } from 'ice'; + +export default defineAppConfig({ + router: { + type: 'hash', + }, +}); diff --git a/examples/hash-router/src/document.tsx b/examples/hash-router/src/document.tsx new file mode 100644 index 000000000..89a9f1af8 --- /dev/null +++ b/examples/hash-router/src/document.tsx @@ -0,0 +1,22 @@ +import { Meta, Title, Links, Main, Scripts } from 'ice'; + +function Document() { + return ( + <html> + <head> + <meta charSet="utf-8" /> + <meta name="description" content="Hash Router Demo" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <Meta /> + <Title /> + <Links /> + </head> + <body> + <Main /> + <Scripts /> + </body> + </html> + ); +} + +export default Document; \ No newline at end of file diff --git a/examples/hash-router/src/pages/about.tsx b/examples/hash-router/src/pages/about.tsx new file mode 100644 index 000000000..9e189d83b --- /dev/null +++ b/examples/hash-router/src/pages/about.tsx @@ -0,0 +1,10 @@ +import { Link } from 'ice'; + +export default function Home() { + return ( + <> + <h2>About Page</h2> + <Link to="/">Home</Link> + </> + ); +} diff --git a/examples/hash-router/src/pages/index.tsx b/examples/hash-router/src/pages/index.tsx new file mode 100644 index 000000000..dbc8578e5 --- /dev/null +++ b/examples/hash-router/src/pages/index.tsx @@ -0,0 +1,10 @@ +import { Link } from 'ice'; + +export default function Home() { + return ( + <> + <h2>Home</h2> + <Link to="/about">about</Link> + </> + ); +} diff --git a/examples/hash-router/src/pages/layout.tsx b/examples/hash-router/src/pages/layout.tsx new file mode 100644 index 000000000..28a84b565 --- /dev/null +++ b/examples/hash-router/src/pages/layout.tsx @@ -0,0 +1,16 @@ +import { Outlet } from 'ice'; + +export default () => { + return ( + <div> + <h1>Layout</h1> + <Outlet /> + </div> + ); +}; + +export function getConfig() { + return { + title: 'Hash Router Demo', + }; +} diff --git a/examples/hash-router/tsconfig.json b/examples/hash-router/tsconfig.json new file mode 100644 index 000000000..7f2f2ffce --- /dev/null +++ b/examples/hash-router/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compileOnSave": false, + "buildOnSave": false, + "compilerOptions": { + "baseUrl": ".", + "outDir": "build", + "module": "esnext", + "target": "es6", + "jsx": "react-jsx", + "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"] +} \ No newline at end of file diff --git a/examples/rax-project/src/document.tsx b/examples/rax-project/src/document.tsx index c4cb093d9..e82d54323 100644 --- a/examples/rax-project/src/document.tsx +++ b/examples/rax-project/src/document.tsx @@ -1,3 +1,5 @@ +/* @jsx createElement */ +import { createElement } from 'rax'; import { Meta, Title, Links, Main, Scripts } from 'ice'; function Document() { diff --git a/examples/with-antd-mobile/package.json b/examples/with-antd-mobile/package.json index af99dd3a2..7021da8ef 100644 --- a/examples/with-antd-mobile/package.json +++ b/examples/with-antd-mobile/package.json @@ -13,7 +13,9 @@ "@ice/app": "workspace:*", "@ice/runtime": "workspace:*", "antd-mobile": "^5.12.4", - "constate": "^3.3.2" + "constate": "^3.3.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { "@types/react": "^18.0.0", diff --git a/examples/with-antd/ice.config.mts b/examples/with-antd/ice.config.mts new file mode 100644 index 000000000..61f091bee --- /dev/null +++ b/examples/with-antd/ice.config.mts @@ -0,0 +1,19 @@ +import { defineConfig } from '@ice/app'; +import antd from '@ice/plugin-antd'; +import moment from '@ice/plugin-moment-locales'; + +export default defineConfig({ + plugins: [ + antd({ + importStyle: true, + dark: true, + compact: true, + theme: { + 'blue-base': '#fd8', + }, + }), + moment({ + locales: ['zh-cn'], + }), + ], +}); diff --git a/examples/with-antd/package.json b/examples/with-antd/package.json new file mode 100644 index 000000000..6a946af4e --- /dev/null +++ b/examples/with-antd/package.json @@ -0,0 +1,27 @@ +{ + "name": "with-antd", + "version": "1.0.0", + "scripts": { + "start": "ice start", + "build": "ice build" + }, + "description": "", + "author": "", + "license": "MIT", + "dependencies": { + "@ice/app": "workspace:*", + "@ice/plugin-antd": "workspace:*", + "@ice/plugin-moment-locales": "workspace:*", + "@ice/runtime": "workspace:*", + "antd": "^4.0.0", + "moment": "^2.29.4", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.2", + "browserslist": "^4.19.3", + "regenerator-runtime": "^0.13.9" + } +} diff --git a/examples/with-antd/src/app.tsx b/examples/with-antd/src/app.tsx new file mode 100644 index 000000000..1ceb78f30 --- /dev/null +++ b/examples/with-antd/src/app.tsx @@ -0,0 +1,7 @@ +import { defineAppConfig } from 'ice'; + +export default defineAppConfig({ + app: { + rootId: 'app', + }, +}); diff --git a/examples/with-antd/src/document.tsx b/examples/with-antd/src/document.tsx new file mode 100644 index 000000000..961b46c16 --- /dev/null +++ b/examples/with-antd/src/document.tsx @@ -0,0 +1,22 @@ +import { Meta, Title, Links, Main, Scripts } from 'ice'; + +function Document() { + return ( + <html> + <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 /> + <Scripts /> + </body> + </html> + ); +} + +export default Document; diff --git a/examples/with-antd/src/global.css b/examples/with-antd/src/global.css new file mode 100644 index 000000000..604282adc --- /dev/null +++ b/examples/with-antd/src/global.css @@ -0,0 +1,3 @@ +body { + font-size: 14px; +} diff --git a/examples/with-antd/src/pages/index.less b/examples/with-antd/src/pages/index.less new file mode 100644 index 000000000..e2e4e7b16 --- /dev/null +++ b/examples/with-antd/src/pages/index.less @@ -0,0 +1,3 @@ +.color { + color: @blue-base; +} \ No newline at end of file diff --git a/examples/with-antd/src/pages/index.tsx b/examples/with-antd/src/pages/index.tsx new file mode 100644 index 000000000..293843c68 --- /dev/null +++ b/examples/with-antd/src/pages/index.tsx @@ -0,0 +1,11 @@ +import { Button } from 'antd'; +import './index.less'; + +export default function Home() { + return ( + <div> + <h1 className="color">antd example</h1> + <Button type="primary">Button</Button> + </div> + ); +} \ No newline at end of file diff --git a/examples/with-antd/src/typings.d.ts b/examples/with-antd/src/typings.d.ts new file mode 100644 index 000000000..b2780a236 --- /dev/null +++ b/examples/with-antd/src/typings.d.ts @@ -0,0 +1,14 @@ +declare module '*.module.less' { + const classes: { [key: string]: string }; + export default classes; +} + +declare module '*.module.css' { + const classes: { [key: string]: string }; + export default classes; +} + +declare module '*.module.scss' { + const classes: { [key: string]: string }; + export default classes; +} diff --git a/examples/with-antd/tsconfig.json b/examples/with-antd/tsconfig.json new file mode 100644 index 000000000..7f2f2ffce --- /dev/null +++ b/examples/with-antd/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compileOnSave": false, + "buildOnSave": false, + "compilerOptions": { + "baseUrl": ".", + "outDir": "build", + "module": "esnext", + "target": "es6", + "jsx": "react-jsx", + "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"] +} \ No newline at end of file diff --git a/examples/with-fusion/.browserslistrc b/examples/with-fusion/.browserslistrc new file mode 100644 index 000000000..7637baddc --- /dev/null +++ b/examples/with-fusion/.browserslistrc @@ -0,0 +1 @@ +chrome 55 \ No newline at end of file diff --git a/examples/with-fusion/ice.config.mts b/examples/with-fusion/ice.config.mts new file mode 100644 index 000000000..678d05927 --- /dev/null +++ b/examples/with-fusion/ice.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from '@ice/app'; +import fusion from '@ice/plugin-fusion'; + +export default defineConfig({ + plugins: [fusion({ + importStyle: true, + theme: { + 'primary-color': '#89d', + }, + })], +}); diff --git a/examples/with-fusion/package.json b/examples/with-fusion/package.json new file mode 100644 index 000000000..18bc2d41d --- /dev/null +++ b/examples/with-fusion/package.json @@ -0,0 +1,24 @@ +{ + "name": "with-fusion", + "version": "1.0.0", + "scripts": { + "start": "ice start", + "build": "ice build" + }, + "description": "", + "author": "", + "license": "MIT", + "dependencies": { + "@alifd/next": "^1.25.49", + "@ice/app": "workspace:*", + "@ice/plugin-fusion": "workspace:*", + "@ice/runtime": "workspace:*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.2", + "regenerator-runtime": "^0.13.9" + } +} diff --git a/examples/with-fusion/src/app.tsx b/examples/with-fusion/src/app.tsx new file mode 100644 index 000000000..1ceb78f30 --- /dev/null +++ b/examples/with-fusion/src/app.tsx @@ -0,0 +1,7 @@ +import { defineAppConfig } from 'ice'; + +export default defineAppConfig({ + app: { + rootId: 'app', + }, +}); diff --git a/examples/with-fusion/src/document.tsx b/examples/with-fusion/src/document.tsx new file mode 100644 index 000000000..961b46c16 --- /dev/null +++ b/examples/with-fusion/src/document.tsx @@ -0,0 +1,22 @@ +import { Meta, Title, Links, Main, Scripts } from 'ice'; + +function Document() { + return ( + <html> + <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 /> + <Scripts /> + </body> + </html> + ); +} + +export default Document; diff --git a/examples/with-fusion/src/global.scss b/examples/with-fusion/src/global.scss new file mode 100644 index 000000000..5056241a9 --- /dev/null +++ b/examples/with-fusion/src/global.scss @@ -0,0 +1,4 @@ +body { + font-size: 14px; + color: $primary-color; +} diff --git a/examples/with-fusion/src/pages/index.tsx b/examples/with-fusion/src/pages/index.tsx new file mode 100644 index 000000000..8561086f7 --- /dev/null +++ b/examples/with-fusion/src/pages/index.tsx @@ -0,0 +1,10 @@ +import { Button } from '@alifd/next'; + +export default function Home() { + return ( + <div> + <h1>with fusion</h1> + <Button type="primary">Button</Button> + </div> + ); +} \ No newline at end of file diff --git a/examples/with-fusion/src/typings.d.ts b/examples/with-fusion/src/typings.d.ts new file mode 100644 index 000000000..b2780a236 --- /dev/null +++ b/examples/with-fusion/src/typings.d.ts @@ -0,0 +1,14 @@ +declare module '*.module.less' { + const classes: { [key: string]: string }; + export default classes; +} + +declare module '*.module.css' { + const classes: { [key: string]: string }; + export default classes; +} + +declare module '*.module.scss' { + const classes: { [key: string]: string }; + export default classes; +} diff --git a/examples/with-fusion/tsconfig.json b/examples/with-fusion/tsconfig.json new file mode 100644 index 000000000..7f2f2ffce --- /dev/null +++ b/examples/with-fusion/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compileOnSave": false, + "buildOnSave": false, + "compilerOptions": { + "baseUrl": ".", + "outDir": "build", + "module": "esnext", + "target": "es6", + "jsx": "react-jsx", + "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"] +} \ No newline at end of file diff --git a/examples/with-pha/src/pages/blog.tsx b/examples/with-pha/src/pages/blog.tsx index deaaaceb5..3400656e9 100644 --- a/examples/with-pha/src/pages/blog.tsx +++ b/examples/with-pha/src/pages/blog.tsx @@ -9,7 +9,7 @@ export default function Blog() { return ( <> <h2>Blog Page</h2> - <Link to="/">home</Link> + <Link to="/home">home</Link> </> ); } diff --git a/examples/with-pha/src/pages/home.tsx b/examples/with-pha/src/pages/home.tsx index 5b312e39c..a2fd14527 100644 --- a/examples/with-pha/src/pages/home.tsx +++ b/examples/with-pha/src/pages/home.tsx @@ -8,6 +8,11 @@ export default function home() { export function getConfig() { return { + queryParamsPassKeys: [ + 'questionId', + 'source', + 'disableNav', + ], title: 'Home', }; } \ No newline at end of file diff --git a/examples/with-store/ice.config.mts b/examples/with-store/ice.config.mts new file mode 100644 index 000000000..902f5727e --- /dev/null +++ b/examples/with-store/ice.config.mts @@ -0,0 +1,8 @@ +import { defineConfig } from '@ice/app'; +import store from '@ice/plugin-store'; + +export default defineConfig({ + plugins: [ + store(), + ], +}); diff --git a/examples/with-store/package.json b/examples/with-store/package.json new file mode 100644 index 000000000..5dd61cb02 --- /dev/null +++ b/examples/with-store/package.json @@ -0,0 +1,21 @@ +{ + "name": "with-store", + "private": true, + "version": "1.0.0", + "scripts": { + "start": "ice start", + "build": "ice build" + }, + "license": "MIT", + "dependencies": { + "@ice/app": "workspace:*", + "@ice/runtime": "workspace:*" + }, + "devDependencies": { + "@ice/plugin-store": "workspace:*", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "browserslist": "^4.19.3", + "regenerator-runtime": "^0.13.9" + } +} \ No newline at end of file diff --git a/examples/with-store/public/favicon.ico b/examples/with-store/public/favicon.ico new file mode 100644 index 000000000..a2605c57e Binary files /dev/null and b/examples/with-store/public/favicon.ico differ diff --git a/examples/with-store/src/app.tsx b/examples/with-store/src/app.tsx new file mode 100644 index 000000000..c1664902e --- /dev/null +++ b/examples/with-store/src/app.tsx @@ -0,0 +1,3 @@ +import { defineAppConfig } from 'ice'; + +export default defineAppConfig({}); diff --git a/examples/with-store/src/document.tsx b/examples/with-store/src/document.tsx new file mode 100644 index 000000000..a61df501e --- /dev/null +++ b/examples/with-store/src/document.tsx @@ -0,0 +1,23 @@ +import { Meta, Title, Links, Main, Scripts } from 'ice'; + +function Document() { + return ( + <html> + <head> + <meta charSet="utf-8" /> + <link rel="icon" href="/favicon.ico" /> + <meta name="description" content="with-store" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <Meta /> + <Title /> + <Links /> + </head> + <body> + <Main /> + <Scripts /> + </body> + </html> + ); +} + +export default Document; diff --git a/examples/with-store/src/models/user.ts b/examples/with-store/src/models/user.ts new file mode 100644 index 000000000..e162baf09 --- /dev/null +++ b/examples/with-store/src/models/user.ts @@ -0,0 +1,7 @@ +import { createModel } from '@ice/plugin-store/esm/runtime'; + +export default createModel({ + state: { + name: 'ICE 3', + }, +}); diff --git a/examples/with-store/src/pages/blog/first-post.tsx b/examples/with-store/src/pages/blog/first-post.tsx new file mode 100644 index 000000000..35aedd9c2 --- /dev/null +++ b/examples/with-store/src/pages/blog/first-post.tsx @@ -0,0 +1,17 @@ +import { Link } from 'ice'; +import pageStore from './store'; + +function FirstPost() { + const [infoState] = pageStore.useModel('info'); + + return ( + <> + <article> + <h2>{infoState.posts[0].title}</h2> + </article> + <div><Link to="/blog">Back</Link></div> + </> + ); +} + +export default FirstPost; diff --git a/examples/with-store/src/pages/blog/index.tsx b/examples/with-store/src/pages/blog/index.tsx new file mode 100644 index 000000000..0213470ef --- /dev/null +++ b/examples/with-store/src/pages/blog/index.tsx @@ -0,0 +1,20 @@ +import { Link } from 'ice'; +import pageStore from './store'; + +function Blog() { + const [infoState] = pageStore.useModel('info'); + + return ( + <div> + Blog Count: {infoState.posts.length} + <ul> + {infoState.posts.map(({ title, id }) => { + return <Link to={id} key={id}><li>{title}</li></Link>; + })} + </ul> + <Link to="/">Home</Link> + </div> + ); +} + +export default Blog; diff --git a/examples/with-store/src/pages/blog/layout.tsx b/examples/with-store/src/pages/blog/layout.tsx new file mode 100644 index 000000000..9a5d94f9c --- /dev/null +++ b/examples/with-store/src/pages/blog/layout.tsx @@ -0,0 +1,14 @@ +import { Outlet } from 'ice'; +import store from './store'; + +function layout() { + const [infoState] = store.useModel('info'); + return ( + <> + <h1>{infoState.title}</h1> + <Outlet /> + </> + ); +} + +export default layout; \ No newline at end of file diff --git a/examples/with-store/src/pages/blog/models/info.ts b/examples/with-store/src/pages/blog/models/info.ts new file mode 100644 index 000000000..c99ee5b20 --- /dev/null +++ b/examples/with-store/src/pages/blog/models/info.ts @@ -0,0 +1,10 @@ +import { createModel } from '@ice/plugin-store/esm/runtime'; + +export default createModel({ + state: { + title: 'ICE Blog', + posts: [ + { title: 'First Post', id: 'first-post' }, + ], + }, +}); diff --git a/examples/with-store/src/pages/blog/store.ts b/examples/with-store/src/pages/blog/store.ts new file mode 100644 index 000000000..4375df669 --- /dev/null +++ b/examples/with-store/src/pages/blog/store.ts @@ -0,0 +1,4 @@ +import { createStore } from '@ice/plugin-store/esm/runtime'; +import info from './models/info'; + +export default createStore({ info }); diff --git a/examples/with-store/src/pages/index.tsx b/examples/with-store/src/pages/index.tsx new file mode 100644 index 000000000..d7a1492f3 --- /dev/null +++ b/examples/with-store/src/pages/index.tsx @@ -0,0 +1,24 @@ +import { Link } from 'ice'; +import pageStore from './store'; +import appStore from '@/store'; + +function Home() { + const [userState] = appStore.useModel('user'); + const [countState, countDispatcher] = pageStore.useModel('counter'); + return ( + <> + <div id="username"> + name: {userState.name} + </div> + <div> + <button type="button" id="inc" onClick={() => countDispatcher.inc()}>+</button> + <span id="count">{countState.count}</span> + <button type="button" id="dec" onClick={() => countDispatcher.dec()}>-</button> + </div> + <Link to="/blog">Blog</Link> + </> + ); +} + + +export default Home; diff --git a/examples/with-store/src/pages/models/counter.ts b/examples/with-store/src/pages/models/counter.ts new file mode 100644 index 000000000..ed62a5603 --- /dev/null +++ b/examples/with-store/src/pages/models/counter.ts @@ -0,0 +1,15 @@ +import { createModel } from '@ice/plugin-store/esm/runtime'; + +export default createModel({ + state: { + count: 0, + }, + reducers: { + inc(prevState, count = 1) { + prevState.count += count; + }, + dec(prevState, count = 1) { + prevState.count -= count; + }, + }, +}); diff --git a/examples/with-store/src/pages/store.ts b/examples/with-store/src/pages/store.ts new file mode 100644 index 000000000..4a59915c7 --- /dev/null +++ b/examples/with-store/src/pages/store.ts @@ -0,0 +1,6 @@ +import { createStore } from '@ice/plugin-store/esm/runtime'; +import counter from './models/counter'; + +const store = createStore({ counter }); + +export default store; diff --git a/examples/with-store/src/store.ts b/examples/with-store/src/store.ts new file mode 100644 index 000000000..5fa480991 --- /dev/null +++ b/examples/with-store/src/store.ts @@ -0,0 +1,4 @@ +import { createStore } from '@ice/plugin-store/esm/runtime'; +import user from './models/user'; + +export default createStore({ user }); diff --git a/examples/with-store/tsconfig.json b/examples/with-store/tsconfig.json new file mode 100644 index 000000000..7f2f2ffce --- /dev/null +++ b/examples/with-store/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compileOnSave": false, + "buildOnSave": false, + "compilerOptions": { + "baseUrl": ".", + "outDir": "build", + "module": "esnext", + "target": "es6", + "jsx": "react-jsx", + "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"] +} \ No newline at end of file diff --git a/package.json b/package.json index 3cf5638f4..eb34bd51a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "lint:fix": "npm run lint -- --fix", "publish:alpha": "PUBLISH_TYPE=alpha esmo ./scripts/publishPackageWithDistTag.ts", "publish:beta": "PUBLISH_TYPE=beta esmo ./scripts/publishPackageWithDistTag.ts", + "publish:release": "PUBLISH_TYPE=release VERSION_PREFIX=rc esmo ./scripts/publishPackageWithDistTag.ts", "cov": "vitest run --coverage", "test": "vitest" }, @@ -30,41 +31,39 @@ "bugs": "https://github.com/ice-lab/ice-next/issues", "homepage": "https://v3.ice.work", "devDependencies": { - "@applint/spec": "^1.0.1", - "@commitlint/cli": "^16.1.0", + "@applint/spec": "^1.2.3", + "@commitlint/cli": "^16.3.0", "@ice/bundles": "workspace:^0.1.0", "@testing-library/react": "^13.3.0", - "@types/eslint": "^8.4.1", + "@types/eslint": "^8.4.5", "@types/fs-extra": "^9.0.13", "@types/glob": "^7.2.0", - "@types/node": "^17.0.13", - "@types/semver": "^7.3.9", + "@types/node": "^17.0.45", + "@types/semver": "^7.3.11", "@vercel/ncc": "^0.33.4", - "c8": "^7.11.0", + "c8": "^7.12.0", "chalk": "^4.1.2", "chokidar": "^3.5.3", "dependency-check": "^4.1.0", "dts-bundle": "^0.7.3", - "eslint": "^8.7.0", - "esno": "^0.14.0", - "execa": "^6.0.0", + "eslint": "^8.21.0", + "esno": "^0.14.1", + "execa": "^6.1.0", "find-up": "^5.0.0", - "fs-extra": "^10.0.0", + "fs-extra": "^10.1.0", "get-port": "^6.1.2", - "glob": "^7.2.0", + "glob": "^7.2.3", "husky": "^7.0.4", - "ice-npm-utils": "^3.0.1", + "ice-npm-utils": "^3.0.2", "jsdom": "^20.0.0", - "prettier": "^2.5.1", - "prettier-plugin-organize-imports": "^2.3.4", - "prettier-plugin-packagejson": "^2.2.15", - "puppeteer": "^13.1.2", - "react": "^18.0.0", - "react-dom": "^18.0.0", + "prettier": "^2.7.1", + "puppeteer": "^13.7.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", "rimraf": "^3.0.2", - "semver": "^7.3.5", - "stylelint": "^14.3.0", - "typescript": "^4.7.0", + "semver": "^7.3.7", + "stylelint": "^14.10.0", + "typescript": "^4.7.4", "vitest": "^0.15.2" }, "packageManager": "pnpm@7.2.1" diff --git a/packages/bundles/package.json b/packages/bundles/package.json index 82e731d6c..15e482113 100644 --- a/packages/bundles/package.json +++ b/packages/bundles/package.json @@ -15,7 +15,7 @@ "main": "./esm/index.js", "type": "module", "dependencies": { - "@swc/core": "1.2.168", + "@swc/core": "1.2.210", "caniuse-lite": "^1.0.30001332", "chokidar": "3.5.3", "events": "3.3.0", diff --git a/packages/ice/bin/ice-cli.mjs b/packages/ice/bin/ice-cli.mjs index ae81556a4..7caac50cd 100755 --- a/packages/ice/bin/ice-cli.mjs +++ b/packages/ice/bin/ice-cli.mjs @@ -30,6 +30,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); .option('--config <config>', 'use custom config') .option('--rootDir <rootDir>', 'project root directory', cwd) .action(async ({ rootDir, ...commandArgs }) => { + process.env.NODE_ENV = 'production'; const service = await createService({ rootDir, command: 'build', commandArgs }); service.run(); }); @@ -42,13 +43,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); .option('--config <config>', 'custom config path') .option('-h, --host <host>', 'dev server host', '0.0.0.0') .option('-p, --port <port>', 'dev server port', 3000) - .option('--no-open', 'don\'t open browser on startup') - .option('--no-mock', 'don\'t start mock service') + .option('--no-open', "don't open browser on startup") + .option('--no-mock', "don't start mock service") .option('--rootDir <rootDir>', 'project root directory', cwd) .option('--analyzer', 'visualize size of output files', false) .option('--https [https]', 'enable https', false) .option('--force', 'force remove cache directory', false) .action(async ({ rootDir, ...commandArgs }) => { + process.env.NODE_ENV = 'development'; commandArgs.port = await detectPort(commandArgs.port); const service = await createService({ rootDir, command: 'start', commandArgs }); service.run(); @@ -62,6 +64,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); .option('--config <config>', 'use custom config') .option('--rootDir <rootDir>', 'project root directory', cwd) .action(async ({ rootDir, ...commandArgs }) => { + process.env.NODE_ENV = 'test'; await createService({ rootDir, command: 'test', commandArgs }); }); diff --git a/packages/ice/package.json b/packages/ice/package.json index f45d30486..b8fecdf7a 100644 --- a/packages/ice/package.json +++ b/packages/ice/package.json @@ -16,7 +16,7 @@ "openChrome.applescript" ], "engines": { - "node": ">=12.22.0", + "node": ">=14.19.0", "npm": ">=3.0.0" }, "scripts": { @@ -29,6 +29,10 @@ "bugs": "https://github.com/ice-lab/ice-next/issues", "homepage": "https://next.ice.work", "dependencies": { + "@babel/generator": "7.18.10", + "@babel/parser": "7.18.10", + "@babel/traverse": "7.18.10", + "@babel/types": "7.18.10", "@ice/bundles": "^0.1.0", "@ice/route-manifest": "^1.0.0", "@ice/runtime": "^1.0.0", @@ -36,8 +40,8 @@ "@ice/webpack-config": "^1.0.0", "acorn": "^8.7.1", "address": "^1.1.2", - "build-scripts": "^2.0.0-23", "body-parser": "^1.20.0", + "build-scripts": "^2.0.0-23", "chalk": "^4.0.0", "commander": "^9.0.0", "consola": "^2.15.3", @@ -59,7 +63,6 @@ "open": "^8.4.0", "path-to-regexp": "^6.2.0", "react-router": "^6.3.0", - "resolve": "^1.22.0", "resolve.exports": "^1.1.0", "sass": "^1.49.9", "semver": "^7.3.5", @@ -68,6 +71,8 @@ "webpack-dev-server": "^4.7.4" }, "devDependencies": { + "@types/babel__traverse": "^7.17.1", + "@types/babel__generator": "^7.6.4", "@types/cross-spawn": "^6.0.2", "@types/ejs": "^3.1.0", "@types/estree": "^0.0.51", @@ -78,7 +83,7 @@ "@types/temp": "^0.9.1", "chokidar": "^3.5.3", "react": "^18.0.0", - "unplugin": "^0.3.2", + "unplugin": "^0.8.0", "webpack": "^5.73.0" }, "peerDependencies": { diff --git a/packages/ice/src/analyzeRuntime.ts b/packages/ice/src/analyzeRuntime.ts deleted file mode 100644 index 0ae285199..000000000 --- a/packages/ice/src/analyzeRuntime.ts +++ /dev/null @@ -1,29 +0,0 @@ -import * as path from 'path'; -import consola from 'consola'; -import type { ServerCompiler } from '@ice/types/esm/plugin.js'; -import type { AppConfig } from '@ice/types'; - -interface Options { - serverCompiler: ServerCompiler; - rootDir: string; -} - -let appConfig: AppConfig; - -export const getAppConfig = (): AppConfig => { - return appConfig; -}; - -export async function compileAppConfig({ rootDir, serverCompiler }: Options) { - const outfile = path.join(rootDir, 'node_modules', 'entry.mjs'); - // TODO: remove top level calls to ensure that appConfig is always returned successfully in build time - await serverCompiler({ - entryPoints: [path.join(rootDir, 'src/app')], - outfile, - format: 'esm', - inject: [], - }); - appConfig = (await import(outfile)).default; - consola.debug('Compile app config by esbuild: ', appConfig); - return appConfig; -} diff --git a/packages/ice/src/commands/build.ts b/packages/ice/src/commands/build.ts index 040d5311c..b7edadc1c 100644 --- a/packages/ice/src/commands/build.ts +++ b/packages/ice/src/commands/build.ts @@ -4,14 +4,15 @@ import { getWebpackConfig } from '@ice/webpack-config'; import type { Context, TaskConfig } from 'build-scripts'; import type { StatsError } from 'webpack'; import type { Config } from '@ice/types'; -import type { ServerCompiler } from '@ice/types/esm/plugin.js'; +import type { ServerCompiler, GetAppConfig, GetRoutesConfig } from '@ice/types/esm/plugin.js'; import webpack from '@ice/bundles/compiled/webpack/index.js'; import type ora from '@ice/bundles/compiled/ora/index.js'; import webpackCompiler from '../service/webpackCompiler.js'; import formatWebpackMessages from '../utils/formatWebpackMessages.js'; -import { RUNTIME_TMP_DIR, SERVER_ENTRY, SERVER_OUTPUT_DIR } from '../constant.js'; +import { RUNTIME_TMP_DIR, SERVER_OUTPUT_DIR } from '../constant.js'; import generateHTML from '../utils/generateHTML.js'; import emptyDir from '../utils/emptyDir.js'; +import getServerEntry from '../utils/getServerEntry.js'; const build = async ( context: Context<Config>, @@ -19,9 +20,11 @@ const build = async ( taskConfigs: TaskConfig<Config>[]; serverCompiler: ServerCompiler; spinner: ora.Ora; + getAppConfig: GetAppConfig; + getRoutesConfig: GetRoutesConfig; }, ) => { - const { taskConfigs, serverCompiler, spinner } = options; + const { taskConfigs, serverCompiler, spinner, getAppConfig, getRoutesConfig } = options; const { applyHook, commandArgs, command, rootDir, userConfig } = context; const webpackConfigs = taskConfigs.map(({ config }) => getWebpackConfig({ config, @@ -33,24 +36,27 @@ const build = async ( const outputDir = webpackConfigs[0].output.path; await emptyDir(outputDir); - + const hooksAPI = { + serverCompiler, + getAppConfig, + getRoutesConfig, + }; const compiler = await webpackCompiler({ rootDir, webpackConfigs, taskConfigs, commandArgs, command, - applyHook, - serverCompiler, spinner, + applyHook, + hooksAPI, }); const { ssg, ssr, server: { format } } = userConfig; // compile server bundle - const entryPoint = path.join(rootDir, SERVER_ENTRY); + const entryPoint = getServerEntry(rootDir, taskConfigs[0].config?.server?.entry); const esm = format === 'esm'; const outJSExtension = esm ? '.mjs' : '.cjs'; const serverOutputDir = path.join(outputDir, SERVER_OUTPUT_DIR); - const documentOnly = !ssg && !ssr; let serverEntry; const { stats, isSuccessful, messages } = await new Promise((resolve, reject): void => { let messages: { errors: string[]; warnings: string[] }; @@ -75,6 +81,7 @@ const build = async ( } else { compiler?.close?.(() => {}); const isSuccessful = !messages.errors.length; + const serverCompilerResult = await serverCompiler( { entryPoints: { index: entryPoint }, @@ -85,14 +92,19 @@ const build = async ( outExtension: { '.js': outJSExtension }, }, { - preBundle: format === 'esm', + preBundle: format === 'esm' && (ssr || ssg), swc: { - // Remove components and getData when document only. - removeExportExprs: documentOnly ? ['default', 'getData', 'getServerData', 'getStaticData'] : [], - jsxTransform: true, + // Remove components and getData when ssg and ssr both `false`. + removeExportExprs: (!ssg && !ssr) ? ['default', 'getData', 'getServerData', 'getStaticData'] : [], + keepPlatform: 'node', }, }, ); + if (serverCompilerResult.error) { + consola.error('Build failed.'); + return; + } + serverEntry = serverCompilerResult.serverEntry; let renderMode; @@ -105,7 +117,8 @@ const build = async ( rootDir, outputDir, entry: serverEntry, - documentOnly, + // only ssg need to generate the whole page html when build time. + documentOnly: !ssg, renderMode, }); resolve({ @@ -124,6 +137,8 @@ const build = async ( taskConfigs, serverCompiler, serverEntry, + getAppConfig, + getRoutesConfig, }); return { compiler }; diff --git a/packages/ice/src/commands/start.ts b/packages/ice/src/commands/start.ts index 663f5014c..ec943f999 100644 --- a/packages/ice/src/commands/start.ts +++ b/packages/ice/src/commands/start.ts @@ -4,7 +4,7 @@ import type { Configuration } from 'webpack-dev-server'; import type { Context, TaskConfig } from 'build-scripts'; import lodash from '@ice/bundles/compiled/lodash/index.js'; import type { Config } from '@ice/types'; -import type { ExtendsPluginAPI, ServerCompiler } from '@ice/types/esm/plugin.js'; +import type { ExtendsPluginAPI, ServerCompiler, GetAppConfig, GetRoutesConfig } from '@ice/types/esm/plugin.js'; import type { AppConfig, RenderMode } from '@ice/runtime'; import { getWebpackConfig } from '@ice/webpack-config'; import webpack from '@ice/bundles/compiled/webpack/index.js'; @@ -13,9 +13,11 @@ import webpackCompiler from '../service/webpackCompiler.js'; import prepareURLs from '../utils/prepareURLs.js'; import createRenderMiddleware from '../middlewares/ssr/renderMiddleware.js'; import createMockMiddleware from '../middlewares/mock/createMiddleware.js'; -import { ROUTER_MANIFEST, RUNTIME_TMP_DIR, SERVER_ENTRY, SERVER_OUTPUT_DIR } from '../constant.js'; +import { ROUTER_MANIFEST, RUNTIME_TMP_DIR, SERVER_OUTPUT_DIR } from '../constant.js'; import ServerCompilerPlugin from '../webpack/ServerCompilerPlugin.js'; -import { getAppConfig } from '../analyzeRuntime.js'; +import ReCompilePlugin from '../webpack/ReCompilePlugin.js'; +import getServerEntry from '../utils/getServerEntry.js'; +import getRouterBasename from '../utils/getRouterBasename.js'; const { merge } = lodash; @@ -27,12 +29,26 @@ const start = async ( appConfig: AppConfig; devPath: string; spinner: ora.Ora; + getAppConfig: GetAppConfig; + getRoutesConfig: GetRoutesConfig; + dataCache: Map<string, string>; + reCompileRouteConfig: (compileKey: string) => void; }, ) => { - const { taskConfigs, serverCompiler, appConfig, devPath, spinner } = options; + const { + taskConfigs, + serverCompiler, + appConfig, + devPath, + spinner, + reCompileRouteConfig, + getAppConfig, + getRoutesConfig, + dataCache, + } = options; const { applyHook, commandArgs, command, rootDir, userConfig, extendsPluginAPI: { serverCompileTask } } = context; const { port, host, https = false } = commandArgs; - + const webTaskConfig = taskConfigs.find(({ name }) => name === 'web'); const webpackConfigs = taskConfigs.map(({ config }) => getWebpackConfig({ config, rootDir, @@ -43,7 +59,7 @@ const start = async ( // Compile server entry after the webpack compilation. const outputDir = webpackConfigs[0].output.path; const { ssg, ssr, server: { format } } = userConfig; - const entryPoint = path.join(rootDir, SERVER_ENTRY); + const entryPoint = getServerEntry(rootDir, taskConfigs[0].config?.server?.entry); const esm = format === 'esm'; const outJSExtension = esm ? '.mjs' : '.cjs'; webpackConfigs[0].plugins.push( @@ -59,11 +75,25 @@ const start = async ( outExtension: { '.js': outJSExtension }, }, { - preBundle: format === 'esm', + preBundle: format === 'esm' && (ssr || ssg), + swc: { + // Remove components and getData when document only. + removeExportExprs: false ? ['default', 'getData', 'getServerData', 'getStaticData'] : [], + keepPlatform: 'node', + }, }, ], serverCompileTask, ), + new ReCompilePlugin(reCompileRouteConfig, (files) => { + // Only when routes file changed. + const routeManifest = JSON.parse(dataCache.get('routes'))?.routeManifest || {}; + const routeFiles = Object.keys(routeManifest).map((key) => { + const { file } = routeManifest[key]; + return `src/pages/${file}`; + }); + return files.some((filePath) => routeFiles.some(routeFile => filePath.includes(routeFile))); + }), ); const customMiddlewares = webpackConfigs[0].devServer?.setupMiddlewares; @@ -79,8 +109,8 @@ const start = async ( } else if (ssg) { renderMode = 'SSG'; } - const appConfig = getAppConfig(); const routeManifestPath = path.join(rootDir, ROUTER_MANIFEST); + // both ssr and ssg, should render the whole page in dev mode. const documentOnly = !ssr && !ssg; const serverRenderMiddleware = createRenderMiddleware({ @@ -88,7 +118,8 @@ const start = async ( routeManifestPath, documentOnly, renderMode, - basename: appConfig?.router?.basename, + getAppConfig, + taskConfig: webTaskConfig, }); const insertIndex = middlewares.findIndex(({ name }) => name === 'serve-index'); middlewares.splice( @@ -106,7 +137,7 @@ const start = async ( // merge devServerConfig with webpackConfig.devServer devServerConfig = merge(webpackConfigs[0].devServer, devServerConfig); const protocol = devServerConfig.https ? 'https' : 'http'; - let urlPathname = appConfig?.router?.basename || '/'; + let urlPathname = getRouterBasename(webTaskConfig, appConfig) || '/'; const urls = prepareURLs( protocol, @@ -114,6 +145,11 @@ const start = async ( devServerConfig.port as number, urlPathname.endsWith('/') ? urlPathname : `${urlPathname}/`, ); + const hooksAPI = { + serverCompiler, + getAppConfig, + getRoutesConfig, + }; const compiler = await webpackCompiler({ rootDir, webpackConfigs, @@ -122,7 +158,7 @@ const start = async ( commandArgs, command, applyHook, - serverCompiler, + hooksAPI, spinner, devPath, }); diff --git a/packages/ice/src/config.ts b/packages/ice/src/config.ts index bee76ff37..1b8679ada 100644 --- a/packages/ice/src/config.ts +++ b/packages/ice/src/config.ts @@ -4,8 +4,10 @@ import fse from 'fs-extra'; import consola from 'consola'; import type { UserConfig, Config } from '@ice/types'; import type { UserConfigContext } from 'build-scripts'; +import lodash from '@ice/bundles/compiled/lodash/index.js'; const require = createRequire(import.meta.url); +const { merge } = lodash; const mergeDefaultValue = <T>(config: Config, key: string, value: T): Config => { if (value) { @@ -43,6 +45,7 @@ const userConfig = [ { name: 'devPublicPath', validation: 'string', + defaultValue: '/', setConfig: (config: Config, publicPath: UserConfig['publicPath'], context: UserConfigContext) => { return mergeDefaultValue(config, 'publicPath', context.command === 'start' && publicPath); }, @@ -50,6 +53,7 @@ const userConfig = [ { name: 'publicPath', validation: 'string', + defaultValue: '/', setConfig: (config: Config, publicPath: UserConfig['publicPath'], context: UserConfigContext) => { return mergeDefaultValue(config, 'publicPath', context.command === 'build' && publicPath); }, @@ -282,6 +286,31 @@ const userConfig = [ name: 'mock', validation: 'object', }, + { + name: 'experimental', + validation: 'object', + }, + { + name: 'syntaxFeatures', + validation: 'object', + setConfig: (config: Config, syntaxFeatures: UserConfig['syntaxFeatures']) => { + if (syntaxFeatures) { + const { exportDefaultFrom, functionBind } = syntaxFeatures; + if (exportDefaultFrom || functionBind) { + config.swcOptions = merge(config.swcOptions, { + compilationConfig: { + jsc: { + parser: { + exportDefaultFrom: !!exportDefaultFrom, + functionBind: !!functionBind, + }, + }, + }, + }); + } + } + }, + }, ]; const cliOption = [ diff --git a/packages/ice/src/constant.ts b/packages/ice/src/constant.ts index 4cba0456a..bd8e59305 100644 --- a/packages/ice/src/constant.ts +++ b/packages/ice/src/constant.ts @@ -13,4 +13,6 @@ export const BUILDIN_ESM_DEPS = [ export const BUILDIN_CJS_DEPS = [ 'react', 'react-dom', + '@uni/env', + 'universal-env', ]; diff --git a/packages/ice/src/createService.ts b/packages/ice/src/createService.ts index 4dbac59ed..6e8e7baf5 100644 --- a/packages/ice/src/createService.ts +++ b/packages/ice/src/createService.ts @@ -14,17 +14,18 @@ import start from './commands/start.js'; import build from './commands/build.js'; import mergeTaskConfig from './utils/mergeTaskConfig.js'; import getWatchEvents from './getWatchEvents.js'; -import { compileAppConfig } from './analyzeRuntime.js'; -import { initProcessEnv, updateRuntimeEnv, getCoreEnvKeys } from './utils/runtimeEnv.js'; +import { setEnv, updateRuntimeEnv, getCoreEnvKeys } from './utils/runtimeEnv.js'; import getRuntimeModules from './utils/getRuntimeModules.js'; import { generateRoutesInfo } from './routes.js'; import getWebTask from './tasks/web/index.js'; -import getDataLoaderTask from './tasks/web/data-loader.js'; import * as config from './config.js'; import createSpinner from './utils/createSpinner.js'; import getRoutePaths from './utils/getRoutePaths.js'; import { RUNTIME_TMP_DIR } from './constant.js'; import ServerCompileTask from './utils/ServerCompileTask.js'; +import { getAppExportConfig, getRouteExportConfig } from './service/config.js'; +import renderExportsTemplate from './utils/renderExportsTemplate.js'; +import { getFileExports } from './service/analyze.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -36,7 +37,7 @@ interface CreateServiceOptions { async function createService({ rootDir, command, commandArgs }: CreateServiceOptions) { const buildSpinner = createSpinner('loading config...'); - const templateDir = path.join(__dirname, '../templates/'); + const templateDir = path.join(__dirname, '../templates/core/'); const configFile = 'ice.config.(mts|mjs|ts|js|cjs|json)'; const dataCache = new Map<string, string>(); const generator = new Generator({ @@ -92,9 +93,6 @@ async function createService({ rootDir, command, commandArgs }: CreateServiceOpt // register web ctx.registerTask('web', getWebTask({ rootDir, command })); - // register data-loader - ctx.registerTask('data-loader', getDataLoaderTask({ rootDir, command })); - // register config ['userConfig', 'cliOption'].forEach((configType) => ctx.registerConfig(configType, config[configType])); @@ -105,25 +103,36 @@ async function createService({ rootDir, command, commandArgs }: CreateServiceOpt // get userConfig after setup because of userConfig maybe modified by plugins const { userConfig } = ctx; - const { routes: routesConfig, server } = userConfig; + const { routes: routesConfig, server, syntaxFeatures } = userConfig; - // load dotenv, set to process.env - await initProcessEnv(rootDir, command, commandArgs); + await setEnv(rootDir, commandArgs); const coreEnvKeys = getCoreEnvKeys(); const routesInfo = await generateRoutesInfo(rootDir, routesConfig); - + const hasExportAppData = (await getFileExports({ rootDir, file: 'src/app' })).includes('getAppData'); const csr = !userConfig.ssr && !userConfig.ssg; // add render data generator.setRenderData({ ...routesInfo, + hasExportAppData, runtimeModules, coreEnvKeys, basename: webTaskConfig.config.basename, + memoryRouter: webTaskConfig.config.memoryRouter, hydrate: !csr, }); - dataCache.set('routes', JSON.stringify(routesInfo.routeManifest)); + dataCache.set('routes', JSON.stringify(routesInfo)); + dataCache.set('hasExportAppData', hasExportAppData ? 'true' : ''); + // Render exports files if route component export getData / getConfig. + renderExportsTemplate({ + ...routesInfo, + hasExportAppData, + }, generator.addRenderFile, { + rootDir, + runtimeDir: RUNTIME_TMP_DIR, + templateDir: path.join(templateDir, '../exports'), + }); // render template before webpack compile const renderStart = new Date().getTime(); @@ -136,7 +145,16 @@ async function createService({ rootDir, command, commandArgs }: CreateServiceOpt task: webTaskConfig, command, server, + syntaxFeatures, }); + const { getAppConfig, init: initAppConfigCompiler } = getAppExportConfig(rootDir); + const { + getRoutesConfig, + init: initRouteConfigCompiler, + reCompile: reCompileRouteConfig, + } = getRouteExportConfig(rootDir); + initAppConfigCompiler(serverCompiler); + initRouteConfigCompiler(serverCompiler); addWatchEvent( ...getWatchEvents({ @@ -152,13 +170,12 @@ async function createService({ rootDir, command, commandArgs }: CreateServiceOpt let appConfig: AppConfig; try { // should after generator, otherwise it will compile error - appConfig = await compileAppConfig({ serverCompiler, rootDir }); + appConfig = (await getAppConfig()).default; } catch (err) { consola.warn('Failed to get app config:', err.message); consola.debug(err); } - const disableRouter = userConfig.removeHistoryDeadCode && routesInfo.routesCount <= 1; if (disableRouter) { consola.info('[ice] removeHistoryDeadCode is enabled and only have one route, ice build will remove history and react-router dead code.'); @@ -176,12 +193,18 @@ async function createService({ rootDir, command, commandArgs }: CreateServiceOpt return await start(ctx, { taskConfigs, serverCompiler, + getRoutesConfig, + getAppConfig, + reCompileRouteConfig, + dataCache, appConfig, devPath: (routePaths[0] || '').replace(/^\//, ''), spinner: buildSpinner, }); } else if (command === 'build') { return await build(ctx, { + getRoutesConfig, + getAppConfig, taskConfigs, serverCompiler, spinner: buildSpinner, diff --git a/packages/ice/src/esbuild/alias.ts b/packages/ice/src/esbuild/alias.ts index 2037e7a57..374787aa2 100644 --- a/packages/ice/src/esbuild/alias.ts +++ b/packages/ice/src/esbuild/alias.ts @@ -6,12 +6,12 @@ import isExternalBuiltinDep from '../utils/isExternalBuiltinDep.js'; interface PluginOptions { alias: Record<string, string | false>; - serverBundle: boolean; + externalDependencies: boolean; format: BuildOptions['format']; } const aliasPlugin = (options: PluginOptions): Plugin => { - const { alias, serverBundle, format } = options; + const { alias, externalDependencies, format } = options; return { name: 'esbuild-alias', setup(build: PluginBuild) { @@ -40,7 +40,7 @@ const aliasPlugin = (options: PluginOptions): Plugin => { build.onResolve({ filter: /.*/ }, (args) => { const id = args.path; // external ids which is third-party dependencies - if (id[0] !== '.' && !path.isAbsolute(id) && !serverBundle && isExternalBuiltinDep(id, format)) { + if (id[0] !== '.' && !path.isAbsolute(id) && externalDependencies && isExternalBuiltinDep(id, format)) { return { external: true, }; diff --git a/packages/plugin-pha/src/removeCodePlugin.ts b/packages/ice/src/esbuild/removeTopLevelCode.ts similarity index 75% rename from packages/plugin-pha/src/removeCodePlugin.ts rename to packages/ice/src/esbuild/removeTopLevelCode.ts index 8c1b4c60c..d5524cd0c 100644 --- a/packages/plugin-pha/src/removeCodePlugin.ts +++ b/packages/ice/src/esbuild/removeTopLevelCode.ts @@ -3,14 +3,14 @@ import type { Plugin } from 'esbuild'; import { parse, type ParserOptions } from '@babel/parser'; import babelTraverse from '@babel/traverse'; import babelGenerate from '@babel/generator'; -import removeTopLevelCode from './removeTopLevelCode.js'; +import removeTopLevelCode from '../utils/babelPluginRemoveCode.js'; // @ts-ignore @babel/traverse is not a valid export in esm -const { default: traverse } = babelTraverse; +const traverse = babelTraverse.default || babelTraverse; // @ts-ignore @babel/generate is not a valid export in esm -const { default: generate } = babelGenerate; +const generate = babelGenerate.default || babelGenerate; -const removeCodePlugin = (): Plugin => { +const removeCodePlugin = (keepExports: string[], transformInclude: (id: string) => boolean): Plugin => { const parserOptions: ParserOptions = { sourceType: 'module', plugins: [ @@ -25,8 +25,7 @@ const removeCodePlugin = (): Plugin => { name: 'esbuild-remove-top-level-code', setup(build) { build.onLoad({ filter: /\.(js|jsx|ts|tsx)$/ }, async ({ path: id }) => { - // TODO: read route file from route-manifest - if (!id.includes('src/pages')) { + if (!transformInclude(id)) { return; } const source = fs.readFileSync(id, 'utf-8'); @@ -36,7 +35,7 @@ const removeCodePlugin = (): Plugin => { parserOptions.plugins.push('typescript', 'decorators-legacy'); } const ast = parse(source, parserOptions); - traverse(ast, removeTopLevelCode()); + traverse(ast, removeTopLevelCode(keepExports)); const contents = generate(ast).code; return { contents, diff --git a/packages/ice/src/esbuild/scan.ts b/packages/ice/src/esbuild/scan.ts index c78af16be..661ef38ac 100644 --- a/packages/ice/src/esbuild/scan.ts +++ b/packages/ice/src/esbuild/scan.ts @@ -1,4 +1,5 @@ import * as path from 'path'; +import { createRequire } from 'module'; import fse from 'fs-extra'; import type { Plugin } from 'esbuild'; import fg from 'fast-glob'; @@ -8,10 +9,17 @@ import { resolveId } from '../service/analyze.js'; import formatPath from '../utils/formatPath.js'; import { ASSET_TYPES } from './assets.js'; +const require = createRequire(import.meta.url); + +export interface DepScanData { + name: string; + pkgPath?: string; +} + interface Options { rootDir: string; alias: Record<string, string | false>; - deps: Record<string, string>; + deps: Record<string, DepScanData>; exclude: string[]; } @@ -24,7 +32,7 @@ const scanPlugin = (options: Options): Plugin => { const dataUrlRE = /^\s*data:/i; const httpUrlRE = /^(https?:)?\/\//; const cache = new Map<string, string | false>(); - const pkgNameCache = new Map<string, string>(); + const pkgNameCache = new Map<string, DepScanData>(); const resolve = (id: string, importer: string) => { const cacheKey = `${id}${importer && path.dirname(importer)}`; if (cache.has(cacheKey)) { @@ -44,15 +52,16 @@ const scanPlugin = (options: Options): Plugin => { return resolved; }; - const getPackageName = (resolved: string) => { + const getPackageData = (resolved: string): DepScanData => { if (pkgNameCache.has(resolved)) { return pkgNameCache.get(resolved); } try { const pkgPath = findUp.sync('package.json', { cwd: resolved }); const pkgInfo = fse.readJSONSync(pkgPath); - pkgNameCache.set(resolved, pkgInfo.name); - return pkgInfo.name; + const result = { name: pkgInfo.name, pkgPath }; + pkgNameCache.set(resolved, result); + return result; } catch (err) { consola.error(`cant resolve package of path: ${resolved}`, err); } @@ -108,7 +117,11 @@ const scanPlugin = (options: Options): Plugin => { if (resolved) { // aliased dependencies if (!path.isAbsolute(resolved) && !id.startsWith('.')) { - deps[id] = resolved; + const { pkgPath } = getPackageData(require.resolve(resolved, { paths: [path.dirname(importer)] })); + deps[id] = { + name: resolved, + pkgPath, + }; return { path: resolved, external: true, @@ -120,7 +133,11 @@ const scanPlugin = (options: Options): Plugin => { resolved.includes('node_modules') || // in case of package with system link when development !formatPath(resolved).includes(formatPath(rootDir))) { - deps[id] = getPackageName(resolved); + const { name, pkgPath } = getPackageData(resolved); + deps[id] = { + name, + pkgPath, + }; return { path: resolved, external: true, diff --git a/packages/ice/src/esbuild/depRedirect.ts b/packages/ice/src/esbuild/transformImport.ts similarity index 74% rename from packages/ice/src/esbuild/depRedirect.ts rename to packages/ice/src/esbuild/transformImport.ts index 826fe9dbc..13464a3ea 100644 --- a/packages/ice/src/esbuild/depRedirect.ts +++ b/packages/ice/src/esbuild/transformImport.ts @@ -1,84 +1,77 @@ import path from 'path'; -import type { Plugin, TransformOptions } from 'esbuild'; +import type { TransformOptions } from 'esbuild'; import { transform } from 'esbuild'; -import fse from 'fs-extra'; import { parse as parseJS } from 'acorn'; import MagicString from 'magic-string'; import esModuleLexer from '@ice/bundles/compiled/es-module-lexer/index.js'; import type { ImportSpecifier } from '@ice/bundles/compiled/es-module-lexer/index.js'; import type { Node } from 'estree'; +import type { UnpluginOptions } from 'unplugin'; import type { DepsMetaData } from '../service/preBundleCJSDeps.js'; const { init, parse } = esModuleLexer; type ImportNameSpecifier = { importedName: string; localName: string }; -/** - * Redirect original dependency to the pre-bundle dependency(cjs) which is handled by preBundleCJSDeps function. - */ -const createDepRedirectPlugin = (metadata: DepsMetaData): Plugin => { +// Redirect original dependency to the pre-bundle dependency(cjs) which is handled by preBundleCJSDeps function. +const transformImportPlugin = (metadata: DepsMetaData): UnpluginOptions => { + const { deps } = metadata; + const redirectDepIds = []; return { - name: 'esbuild-dep-redirect', - setup(build) { - const { deps } = metadata; - const redirectDepIds = []; - - build.onResolve({ filter: /.*/ }, ({ path: id }) => { - if (redirectDepIds.includes(id)) { - return { - path: id, - external: true, - }; - } - }); - build.onLoad({ filter: /\.(js|jsx|ts|tsx)$/ }, async ({ path: id }) => { - await init; - let source = await fse.readFile(id, 'utf-8'); - let imports: readonly ImportSpecifier[] = []; - const transformed = await transformWithESBuild( - source, + name: 'transform-import', + resolveId(id) { + if (redirectDepIds.includes(id)) { + return { id, - ); - source = transformed.code; - imports = parse(transformed.code)[0]; - const str = new MagicString(source); - for (let index = 0; index < imports.length; index++) { - const { - // depId start and end - s: start, - e: end, - ss: expStart, - se: expEnd, - n: specifier, - } = imports[index]; - if (!(specifier in deps)) { - continue; - } - - const importExp = source.slice(expStart, expEnd); - const filePath = deps[specifier].file; - redirectDepIds.push(filePath); - const rewritten = transformCjsImport( - importExp, - filePath, - specifier, - index, - ); - if (rewritten) { - str.overwrite(expStart, expEnd, rewritten, { - contentOnly: true, - }); - } else { - // export * from '...' - str.overwrite(start, end, filePath, { contentOnly: true }); - } + external: true, + }; + } + }, + transformInclude(id: string) { + return /\.(js|jsx|ts|tsx)$/.test(id); + }, + async transform(source: string, id: string) { + await init; + let imports: readonly ImportSpecifier[] = []; + const transformed = await transformWithESBuild( + source, + id, + ); + source = transformed.code; + imports = parse(transformed.code)[0]; + const str = new MagicString(source); + for (let index = 0; index < imports.length; index++) { + const { + // depId start and end + s: start, + e: end, + ss: expStart, + se: expEnd, + n: specifier, + } = imports[index]; + if (!(specifier in deps)) { + continue; } - const contents = str.toString(); - return { - contents, - }; - }); + const importExp = source.slice(expStart, expEnd); + const filePath = deps[specifier].file; + redirectDepIds.push(filePath); + const rewritten = transformCjsImport( + importExp, + filePath, + specifier, + index, + ); + if (rewritten) { + str.overwrite(expStart, expEnd, rewritten, { + contentOnly: true, + }); + } else { + // export * from '...' + str.overwrite(start, end, filePath, { contentOnly: true }); + } + } + return str.toString(); }, }; }; @@ -207,4 +200,4 @@ function makeLegalIdentifier(str: string) { return identifier || '_'; } -export default createDepRedirectPlugin; \ No newline at end of file +export default transformImportPlugin; \ No newline at end of file diff --git a/packages/ice/src/esbuild/transformPipe.ts b/packages/ice/src/esbuild/transformPipe.ts new file mode 100644 index 000000000..5e1ccf6c0 --- /dev/null +++ b/packages/ice/src/esbuild/transformPipe.ts @@ -0,0 +1,153 @@ +import fs from 'fs'; +import * as path from 'path'; +import type { Plugin, PluginBuild, Loader } from 'esbuild'; +import type { UnpluginOptions, UnpluginContext } from 'unplugin'; + +interface PluginOptions { + plugins?: UnpluginOptions[]; + namespace?: string; + filter?: RegExp; +} + +const extToLoader: Record<string, Loader> = { + '.js': 'js', + '.mjs': 'js', + '.cjs': 'js', + '.jsx': 'jsx', + '.ts': 'ts', + '.cts': 'ts', + '.mts': 'ts', + '.tsx': 'tsx', + '.css': 'css', + '.less': 'css', + '.stylus': 'css', + '.scss': 'css', + '.sass': 'css', + '.json': 'json', + '.txt': 'text', +}; + +export function guessLoader(id: string): Loader { + return extToLoader[path.extname(id).toLowerCase()] || 'js'; +} + +/** + * `load` and `transform` may return a sourcemap without toString and toUrl, + * but esbuild needs them, we fix the two methods. + */ +export function fixSourceMap(map: any) { + if (!('toString' in map)) { + Object.defineProperty(map, 'toString', { + enumerable: false, + value: function toString() { + return JSON.stringify(this); + }, + }); + } + if (!('toUrl' in map)) { + Object.defineProperty(map, 'toUrl', { + enumerable: false, + value: function toUrl() { + return `data:application/json;charset=utf-8;base64,${Buffer.from(this.toString()).toString('base64')}`; + }, + }); + } + return map; +} + +const transformPipe = (options: PluginOptions = {}): Plugin => { + return { + name: 'esbuild-transform-pipe', + setup(build: PluginBuild) { + const { plugins = [], namespace = '', filter = /.*/ } = options; + const errors = []; + const warnings = []; + + // TODO: support unplugin context such as parse / emitFile + const pluginContext: UnpluginContext = { + error(message) { errors.push({ text: String(message) }); }, + warn(message) { warnings.push({ text: String(message) }); }, + }; + const pluginResolveIds = []; + plugins.forEach(plugin => { + // Call esbuild specific Logic like onResolve. + plugin?.esbuild?.setup(build); + if (plugin?.resolveId) { + pluginResolveIds.push(plugin?.resolveId); + } + }); + if (pluginResolveIds.length > 0) { + build.onResolve({ filter }, async (args) => { + const isEntry = args.kind === 'entry-point'; + const res = await pluginResolveIds.reduce(async (resolveData, resolveId) => { + const { path, external } = await resolveData; + if (!external) { + const result = await resolveId(path, isEntry ? undefined : args.importer, { isEntry }); + if (typeof result === 'string') { + return { path: result }; + } else if (typeof result === 'object' && result !== null) { + return { path: result.id, external: result.external }; + } + } + return resolveData; + }, Promise.resolve({ path: args.path })); + if (path.isAbsolute(res.path) || res.external) { + return res; + } + }); + } + build.onLoad({ filter, namespace }, async (args) => { + const id = args.path; + // it is required to forward `resolveDir` for esbuild to find dependencies. + const resolveDir = path.dirname(args.path); + const loader = guessLoader(id); + const transformedResult = await plugins.reduce(async (prevData, plugin) => { + const { contents } = await prevData; + const { transform, transformInclude } = plugin; + if (!transformInclude || transformInclude?.(id)) { + let sourceCode = contents; + let sourceMap = null; + if (plugin.load) { + const result = await plugin.load.call(pluginContext, id); + if (typeof result === 'string') { + sourceCode = result; + } else if (typeof result === 'object' && result !== null) { + sourceCode = result.code; + sourceMap = result.map; + } + } + if (!sourceCode) { + // Caution: 'utf8' assumes the input file is not in binary. + // If you want your plugin handle binary files, make sure to execute `plugin.load()` first. + sourceCode = await fs.promises.readFile(args.path, 'utf8'); + } + if (transform) { + const result = await transform.call(pluginContext, sourceCode, id); + if (typeof result === 'string') { + sourceCode = result; + } else if (typeof result === 'object' && result !== null) { + sourceCode = result.code; + sourceMap = result.map; + } + } + if (sourceMap) { + if (!sourceMap.sourcesContent || sourceMap.sourcesContent.length === 0) { + sourceMap.sourcesContent = [sourceCode]; + } + sourceMap = fixSourceMap(sourceMap); + sourceCode += `\n//# sourceMappingURL=${sourceMap.toUrl()}`; + } + return { contents: sourceCode, resolveDir, loader }; + } + return { contents, resolveDir, loader }; + }, Promise.resolve({ contents: null, resolveDir, loader })); + // Make sure contents is not null when return. + if (transformedResult.contents) { + return transformedResult; + } + }); + }, + }; +}; + +export default transformPipe; \ No newline at end of file diff --git a/packages/ice/src/getWatchEvents.ts b/packages/ice/src/getWatchEvents.ts index 3fc9f4414..6dc23410d 100644 --- a/packages/ice/src/getWatchEvents.ts +++ b/packages/ice/src/getWatchEvents.ts @@ -5,8 +5,9 @@ import type { Context } from 'build-scripts'; import type { Config } from '@ice/types'; import { generateRoutesInfo } from './routes.js'; import type Generator from './service/runtimeGenerator'; -import { compileAppConfig } from './analyzeRuntime.js'; import getGlobalStyleGlobPattern from './utils/getGlobalStyleGlobPattern.js'; +import renderExportsTemplate from './utils/renderExportsTemplate.js'; +import { getFileExports } from './service/analyze.js'; interface Options { targetDir: string; @@ -18,32 +19,38 @@ interface Options { } const getWatchEvents = (options: Options): WatchEvent[] => { - const { serverCompiler, generator, targetDir, templateDir, cache, ctx } = options; + const { generator, targetDir, templateDir, cache, ctx } = options; const { userConfig: { routes: routesConfig }, configFile, rootDir } = ctx; const watchRoutes: WatchEvent = [ /src\/pages\/?[\w*-:.$]+$/, async (eventName: string) => { - if (eventName === 'add' || eventName === 'unlink') { + if (eventName === 'add' || eventName === 'unlink' || eventName === 'change') { const routesRenderData = await 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, 'routes.ts'), - routesRenderData, - ); - generator.renderFile( - path.join(templateDir, 'route-manifest.json.ejs'), - path.join(rootDir, targetDir, 'route-manifest.json'), - routesRenderData, - ); - generator.renderFile( - path.join(templateDir, 'data-loader.ts.ejs'), - path.join(rootDir, targetDir, 'data-loader.ts'), - routesRenderData, - ); + if (eventName !== 'change') { + // Specify the route files to re-render. + generator.renderFile( + path.join(templateDir, 'routes.ts.ejs'), + path.join(rootDir, targetDir, 'routes.ts'), + routesRenderData, + ); + generator.renderFile( + path.join(templateDir, 'route-manifest.json.ejs'), + path.join(rootDir, targetDir, 'route-manifest.json'), + routesRenderData, + ); + } + renderExportsTemplate({ + ...routesRenderData, + hasExportAppData: !!cache.get('hasExportAppData'), + }, generator.renderFile, { + rootDir, + runtimeDir: targetDir, + templateDir: path.join(templateDir, '../exports'), + }); } } }, @@ -84,8 +91,18 @@ const getWatchEvents = (options: Options): WatchEvent[] => { /src\/app.(js|jsx|ts|tsx)/, async (event: string) => { if (event === 'change') { - consola.debug('[event]', 'Compile app config.'); - await compileAppConfig({ rootDir, serverCompiler }); + const hasExportAppData = (await getFileExports({ rootDir, file: 'src/app' })).includes('getAppData'); + if (hasExportAppData !== !!cache.get('hasExportAppData')) { + cache.set('hasExportAppData', hasExportAppData ? 'true' : ''); + renderExportsTemplate({ + ...JSON.parse(cache.get('routes')), + hasExportAppData, + }, generator.renderFile, { + rootDir, + runtimeDir: targetDir, + templateDir: path.join(templateDir, '../exports'), + }); + } } }, ]; diff --git a/packages/ice/src/middlewares/ssr/renderMiddleware.ts b/packages/ice/src/middlewares/ssr/renderMiddleware.ts index e32b11540..11068801f 100644 --- a/packages/ice/src/middlewares/ssr/renderMiddleware.ts +++ b/packages/ice/src/middlewares/ssr/renderMiddleware.ts @@ -6,6 +6,9 @@ import consola from 'consola'; // @ts-expect-error FIXME: esm type error import matchRoutes from '@ice/runtime/matchRoutes'; import type { ExtendsPluginAPI } from '@ice/types/esm/plugin.js'; +import type { TaskConfig } from 'build-scripts'; +import type { Config } from '@ice/types'; +import getRouterBasename from '../../utils/getRouterBasename.js'; const require = createRequire(import.meta.url); @@ -14,18 +17,23 @@ interface Options { routeManifestPath: string; documentOnly?: boolean; renderMode?: RenderMode; - basename?: string; + taskConfig?: TaskConfig<Config>; + getAppConfig: () => Promise<any>; } export default function createRenderMiddleware(options: Options): Middleware { - const { documentOnly, renderMode, serverCompileTask, routeManifestPath, basename } = options; + const { documentOnly, renderMode, serverCompileTask, routeManifestPath, getAppConfig, taskConfig } = options; const middleware: ExpressRequestHandler = async function (req, res, next) { const routes = JSON.parse(fse.readFileSync(routeManifestPath, 'utf-8')); + const basename = getRouterBasename(taskConfig, (await getAppConfig()).default); const matches = matchRoutes(routes, req.path, basename); if (matches.length) { // Wait for the server compilation to finish - const { serverEntry } = await serverCompileTask.get(); - + const { serverEntry, error } = await serverCompileTask.get(); + if (error) { + consola.error('Server compile error in render middleware.'); + return; + } let serverModule; try { delete require.cache[serverEntry]; diff --git a/packages/ice/src/routes.ts b/packages/ice/src/routes.ts index eef29ab50..501bdef06 100644 --- a/packages/ice/src/routes.ts +++ b/packages/ice/src/routes.ts @@ -1,32 +1,22 @@ import * as path from 'path'; import { formatNestedRouteManifest, generateRouteManifest } from '@ice/route-manifest'; -import type { NestedRouteManifest, ConfigRoute, RouteManifest } from '@ice/route-manifest'; +import type { NestedRouteManifest } from '@ice/route-manifest'; import type { UserConfig } from '@ice/types'; -import { getRouteExports } from './service/analyze.js'; +import { getFileExports } from './service/analyze.js'; export async function generateRoutesInfo(rootDir: string, routesConfig: UserConfig['routes'] = {}) { const routeManifest = generateRouteManifest(rootDir, routesConfig.ignoreFiles, routesConfig.defineRoutes); const analyzeTasks = Object.keys(routeManifest).map(async (key) => { const routeItem = routeManifest[key]; - const routeId = routeItem.id; // add exports filed for route manifest - routeItem.exports = await getRouteExports({ + routeItem.exports = await getFileExports({ rootDir, - routeConfig: { - file: path.join('./src/pages', routeItem.file), - routeId, - }, + file: path.join('./src/pages', routeItem.file), }); }); await Promise.all(analyzeTasks); - if (!routeManifest['$']) { - // create default 404 page - const defaultNotFoundRoute = createDefaultNotFoundRoute(routeManifest); - routeManifest['$'] = defaultNotFoundRoute; - } - const routes = formatNestedRouteManifest(routeManifest); const routesStr = generateRoutesStr(routes); let routesCount = 0; @@ -43,16 +33,16 @@ export async function generateRoutesInfo(rootDir: string, routesConfig: UserConf routesStr, routes, loaders: generateRouteConfig(routes, 'getData', (str, imports) => { - return `${str} + return imports.length > 0 ? `${str} const loaders = { ${imports.map(([routeId, importKey]) => `'${routeId}': ${importKey},`).join('\n ')} -};`; +};` : ''; }), routesConfig: generateRouteConfig(routes, 'getConfig', (str, imports) => { - return `${str} + return imports.length > 0 ? `${str} export default { ${imports.map(([, importKey, routePath]) => `'${routePath}': ${importKey},`).join('\n ')} -};`; +};` : ''; }), }; } @@ -97,19 +87,6 @@ ${identSpaces + twoSpaces}${strs.join(`\n${`${identSpaces + twoSpaces}`}`)} ${identSpaces}},`; } -function createDefaultNotFoundRoute(routeManifest: RouteManifest): ConfigRoute { - return { - path: '*', - // TODO: git warning if the id startsWith __ - id: '__404', - parentId: routeManifest['layout'] ? 'layout' : null, - file: './404.tsx', - componentName: '__404', - layout: false, - exports: ['default'], - }; -} - /** * generate loader template for routes */ @@ -130,7 +107,7 @@ function generateRouteConfig( const componentFile = file.replace(new RegExp(`${fileExtname}$`), ''); const componentPath = path.isAbsolute(componentFile) ? componentFile : `@/pages/${componentFile}`; - const loaderName = `${exportKey}_${id}`.replace('/', '_'); + const loaderName = `${exportKey}_${id}`.replace(/[-/]/g, '_'); const routePath = route.path || (route.index ? 'index' : '/'); const fullPath = path.join(parentPath, routePath); imports.push([id, loaderName, fullPath]); diff --git a/packages/ice/src/service/analyze.ts b/packages/ice/src/service/analyze.ts index 0c50f714a..63016bcab 100644 --- a/packages/ice/src/service/analyze.ts +++ b/packages/ice/src/service/analyze.ts @@ -4,12 +4,12 @@ import fs from 'fs-extra'; import fg from 'fast-glob'; import moduleLexer from '@ice/bundles/compiled/es-module-lexer/index.js'; import { transform, build } from 'esbuild'; -import type { Loader } from 'esbuild'; +import type { Loader, Plugin } from 'esbuild'; import consola from 'consola'; -import { getRouteCache, setRouteCache } from '../utils/persistentCache.js'; +import { getCache, setCache } from '../utils/persistentCache.js'; import { getFileHash } from '../utils/hash.js'; - import scanPlugin from '../esbuild/scan.js'; +import type { DepScanData } from '../esbuild/scan.js'; interface Options { parallel?: number; @@ -34,7 +34,7 @@ export function resolveId(id: string, alias: Alias) { const strictKey = isStrict ? aliasKey.slice(0, -1) : aliasKey; const aliasValue = alias[aliasKey]; if (!aliasValue) return false; - if (aliasValue.match(/.(j|t)s(x)?$/)) { + if (aliasValue.match(/\.(j|t)s(x)?$/)) { if (aliasedPath === strictKey) { aliasedPath = aliasValue; break; @@ -158,85 +158,99 @@ export async function analyzeImports(files: string[], options: Options) { interface ScanOptions { rootDir: string; alias?: Alias; - depImports?: Record<string, string>; + depImports?: Record<string, DepScanData>; + plugins?: Plugin[]; exclude?: string[]; } export async function scanImports(entries: string[], options?: ScanOptions) { const start = performance.now(); - const { alias = {}, depImports = {}, exclude = [], rootDir } = options; + const { alias = {}, depImports = {}, exclude = [], rootDir, plugins } = options; const deps = { ...depImports }; - await Promise.all( - entries.map((entry) => - build({ - absWorkingDir: rootDir, - write: false, - entryPoints: [entry], - bundle: true, - format: 'esm', - logLevel: 'silent', - loader: { '.js': 'jsx' }, - plugins: [scanPlugin({ - rootDir, - deps, - alias, - exclude, - })], - }), - )); - consola.debug(`Scan completed in ${(performance.now() - start).toFixed(2)}ms:`, deps); + try { + await Promise.all( + entries.map((entry) => + build({ + absWorkingDir: rootDir, + write: false, + entryPoints: [entry], + bundle: true, + format: 'esm', + logLevel: 'silent', + loader: { '.js': 'jsx' }, + plugins: [ + scanPlugin({ + rootDir, + deps, + alias, + exclude, + }), + ...(plugins || []), + ], + }), + ), + ); + consola.debug(`Scan completed in ${(performance.now() - start).toFixed(2)}ms:`, deps); + } catch (error) { + consola.error('Failed to scan imports.'); + consola.debug(error); + } return orderedDependencies(deps); } -function orderedDependencies(deps: Record<string, string>) { +function orderedDependencies(deps: Record<string, DepScanData>) { const depsList = Object.entries(deps); // Ensure the same browserHash for the same set of dependencies depsList.sort((a, b) => a[0].localeCompare(b[0])); return Object.fromEntries(depsList); } -interface RouteOptions { + +interface FileOptions { + file: string; rootDir: string; - routeConfig: { - file: string; - routeId: string; - }; } type CachedRouteExports = { hash: string; exports: string[] }; -export async function getRouteExports(options: RouteOptions): Promise<string[]> { - const { rootDir, routeConfig: { file, routeId } } = options; - const routePath = path.join(rootDir, file); +export async function getFileExports(options: FileOptions): Promise<CachedRouteExports['exports']> { + const { rootDir, file } = options; + const filePath = path.join(rootDir, file); let cached: CachedRouteExports | null = null; try { - cached = await getRouteCache(rootDir, routeId); + cached = await getCache(rootDir, filePath); } catch (err) { // ignore cache error } - const fileHash = await getFileHash(routePath); + const fileHash = await getFileHash(filePath); if (!cached || cached.hash !== fileHash) { - // get route export by esbuild - const result = await build({ - loader: { '.js': 'jsx' }, - entryPoints: [routePath], - platform: 'neutral', - format: 'esm', - metafile: true, - write: false, - logLevel: 'silent', - }); - for (let key in result.metafile.outputs) { - let output = result.metafile.outputs[key]; - if (output.entryPoint) { - cached = { - exports: output.exports, - hash: fileHash, - }; - // write cached - setRouteCache(rootDir, routeId, cached); - break; + try { + // get route export by esbuild + const result = await build({ + loader: { '.js': 'jsx' }, + entryPoints: [filePath], + platform: 'neutral', + format: 'esm', + metafile: true, + write: false, + logLevel: 'silent', + }); + + for (let key in result.metafile.outputs) { + let output = result.metafile.outputs[key]; + if (output.entryPoint) { + cached = { + exports: output.exports, + hash: fileHash, + }; + // write cached + setCache(rootDir, filePath, cached); + break; + } } + } catch (error) { + consola.error(`Failed to get route ${filePath} exports.`); + consola.debug(error); } } return cached.exports; diff --git a/packages/ice/src/service/config.ts b/packages/ice/src/service/config.ts new file mode 100644 index 000000000..646860b31 --- /dev/null +++ b/packages/ice/src/service/config.ts @@ -0,0 +1,162 @@ +import * as path from 'path'; +import fs from 'fs-extra'; +import type { ServerCompiler } from '@ice/types/esm/plugin.js'; +import removeTopLevelCode from '../esbuild/removeTopLevelCode.js'; +import { getCache, setCache } from '../utils/persistentCache.js'; +import { getFileHash } from '../utils/hash.js'; +import { RUNTIME_TMP_DIR } from '../constant.js'; + +type GetOutfile = (entry: string, exportNames: string[]) => string; + +interface CompileConfig { + entry: string; + rootDir: string; + transformInclude: (id: string) => boolean; + needRecompile?: (entry: string, options: string[]) => Promise<boolean | string>; + getOutfile?: GetOutfile; +} + +class Config { + private compileTasks: Record<string, Promise<string>>; + private compiler: (keepExports: string[]) => Promise<string>; + private compileConfig: CompileConfig; + private lastOptions: string[]; + private getOutfile: GetOutfile; + private status: 'PENDING' | 'RESOLVED'; + + constructor(compileConfig: CompileConfig) { + const { rootDir, entry } = compileConfig; + this.compileTasks = {}; + this.compileConfig = compileConfig; + this.lastOptions = []; + this.status = 'PENDING'; + this.getOutfile = compileConfig.getOutfile || + (() => path.join(rootDir, 'node_modules', `${path.basename(entry)}.mjs`)); + } + + public setCompiler(esbuildCompiler: ServerCompiler): void { + this.compiler = async (keepExports) => { + const { entry, transformInclude } = this.compileConfig; + const outfile = this.getOutfile(entry, keepExports); + this.status = 'PENDING'; + await esbuildCompiler({ + entryPoints: [entry], + format: 'esm', + inject: [], + outfile, + plugins: [removeTopLevelCode(keepExports, transformInclude)], + }); + this.status = 'RESOLVED'; + return `${outfile}?version=${new Date().getTime()}`; + }; + } + + public reCompile = (taskKey: string) => { + // Re-compile only triggered when `getConfig` has been called. + if (this.compileTasks[taskKey]) { + this.compileTasks[taskKey] = this.compiler(this.lastOptions); + } + }; + + public getConfig = async (keepExports: string[]) => { + const taskKey = keepExports.join('_'); + this.lastOptions = keepExports; + let targetFile = ''; + // Check file hash if it need to be re compiled + if (this.compileConfig?.needRecompile) { + const outfile = this.getOutfile(this.compileConfig.entry, keepExports); + const cached = await this.compileConfig?.needRecompile(outfile, keepExports); + if (cached && typeof cached === 'string') { + targetFile = this.status === 'RESOLVED' ? `${cached}?version=${new Date().getTime()}` + : (await this.compileTasks[taskKey]); + } else if (!cached) { + this.reCompile(taskKey); + } + } + if (!this.compileTasks[taskKey]) { + this.compileTasks[taskKey] = this.compiler(keepExports); + } + if (!targetFile) { + targetFile = await this.compileTasks[taskKey]; + } + return await import(targetFile); + }; +} + +export const getAppExportConfig = (rootDir: string) => { + const appEntry = path.join(rootDir, 'src/app'); + const getOutfile = (entry: string, keepExports: string[]) => + path.join(rootDir, 'node_modules', `${keepExports.join('_')}_${path.basename(entry)}.mjs`); + const appExportConfig = new Config({ + entry: appEntry, + rootDir, + // Only remove top level code for src/app. + transformInclude: (id) => id.includes('src/app') || id.includes('.ice'), + getOutfile, + needRecompile: async (entry, keepExports) => { + let cached = null; + const cachedKey = `app_${keepExports.join('_')}`; + try { + cached = await getCache(rootDir, cachedKey); + } catch (err) {} + const fileHash = await getFileHash(appEntry); + if (!cached || fileHash !== cached) { + await setCache(rootDir, cachedKey, fileHash); + return false; + } + return entry; + }, + }); + + const getAppConfig = async (exportNames?: string[]) => { + return await appExportConfig.getConfig(exportNames || ['default', 'defineAppConfig']); + }; + + return { + init(serverCompiler: ServerCompiler) { + appExportConfig.setCompiler(serverCompiler); + }, + getAppConfig, + }; +}; + +export const getRouteExportConfig = (rootDir: string) => { + const routeConfigFile = path.join(rootDir, RUNTIME_TMP_DIR, 'routes-config.ts'); + const routeExportConfig = new Config({ + entry: routeConfigFile, + rootDir, + // Only remove top level code for route component file. + transformInclude: (id) => id.includes('src/pages'), + needRecompile: async (entry) => { + let cached = false; + const cachedKey = 'route_config_file'; + try { + cached = await getCache(rootDir, cachedKey); + } catch (err) {} + if (cached) { + // Always use cached file path while `routes-config` trigger re-compile by webpack plugin. + return entry; + } else { + setCache(rootDir, cachedKey, 'true'); + return false; + } + }, + }); + const getRoutesConfig = async (specifyRoutId?: string) => { + // Routes config file may be removed after file changed. + if (!fs.existsSync(routeConfigFile)) { + return undefined; + } + const routeConfig = (await routeExportConfig.getConfig(['getConfig'])).default; + return specifyRoutId ? routeConfig[specifyRoutId] : routeConfig; + }; + return { + init(serverCompiler: ServerCompiler) { + routeExportConfig.setCompiler(serverCompiler); + }, + getRoutesConfig, + reCompile: routeExportConfig.reCompile, + }; +}; + +export default Config; \ No newline at end of file diff --git a/packages/ice/src/service/preBundleCJSDeps.ts b/packages/ice/src/service/preBundleCJSDeps.ts index b4f521d5f..fb76b1f7c 100644 --- a/packages/ice/src/service/preBundleCJSDeps.ts +++ b/packages/ice/src/service/preBundleCJSDeps.ts @@ -1,28 +1,16 @@ import path from 'path'; import { createHash } from 'crypto'; +import consola from 'consola'; import fse from 'fs-extra'; import { build } from 'esbuild'; +import type { Plugin } from 'esbuild'; import { resolve as resolveExports } from 'resolve.exports'; -import resolve from 'resolve'; import moduleLexer from '@ice/bundles/compiled/es-module-lexer/index.js'; import type { Config } from '@ice/types'; import flattenId from '../utils/flattenId.js'; import formatPath from '../utils/formatPath.js'; import { BUILDIN_CJS_DEPS, BUILDIN_ESM_DEPS } from '../constant.js'; - -interface PackageData { - data: { - name: string; - type: string; - version: string; - main: string; - module: string; - exports: string | Record<string, any> | string[]; - dependencies: Record<string, string>; - [field: string]: any; - }; - dir: string; -} +import type { DepScanData } from '../esbuild/scan.js'; interface DepInfo { file: string; @@ -39,17 +27,17 @@ interface PreBundleDepsResult { } interface PreBundleDepsOptions { - depsInfo: Record<string, string>; - rootDir: string; + depsInfo: Record<string, DepScanData>; cacheDir: string; taskConfig: Config; + plugins?: Plugin[]; } /** * Pre bundle dependencies from esm to cjs. */ export default async function preBundleCJSDeps(options: PreBundleDepsOptions): Promise<PreBundleDepsResult> { - const { depsInfo, rootDir, cacheDir, taskConfig } = options; + const { depsInfo, cacheDir, taskConfig, plugins = [] } = options; const metadata = createDepsMetadata(depsInfo, taskConfig); if (!Object.keys(depsInfo)) { @@ -76,7 +64,7 @@ export default async function preBundleCJSDeps(options: PreBundleDepsOptions): P await moduleLexer.init; for (const depId in depsInfo) { - const packageEntry = resolvePackageEntry(depId, rootDir); + const packageEntry = resolvePackageEntry(depId, depsInfo[depId].pkgPath); const flatId = flattenId(depId); flatIdDeps[flatId] = packageEntry; @@ -88,19 +76,24 @@ export default async function preBundleCJSDeps(options: PreBundleDepsOptions): P }; } - await build({ - absWorkingDir: process.cwd(), - entryPoints: flatIdDeps, - bundle: true, - logLevel: 'error', - sourcemap: true, - outdir: depsCacheDir, - format: 'cjs', - platform: 'node', - loader: { '.js': 'jsx' }, - ignoreAnnotations: true, - external: [...BUILDIN_CJS_DEPS, ...BUILDIN_ESM_DEPS], - }); + try { + await build({ + absWorkingDir: process.cwd(), + entryPoints: flatIdDeps, + bundle: true, + logLevel: 'error', + outdir: depsCacheDir, + format: 'cjs', + platform: 'node', + loader: { '.js': 'jsx' }, + ignoreAnnotations: true, + plugins, + external: [...BUILDIN_CJS_DEPS, ...BUILDIN_ESM_DEPS], + }); + } catch (error) { + consola.error('Failed to bundle dependencies.'); + consola.debug(error); + } await fse.writeJSON(metadataJSONPath, metadata, { spaces: 2 }); @@ -109,38 +102,20 @@ export default async function preBundleCJSDeps(options: PreBundleDepsOptions): P }; } -function resolvePackageEntry(depId: string, rootDir: string) { - const { data: pkgJSONData, dir } = resolvePackageData(depId, rootDir); +function resolvePackageEntry(depId: string, pkgPath: string) { + const pkgJSON = fse.readJSONSync(pkgPath); + const pkgDir = path.dirname(pkgPath); // resolve exports cjs field - let entryPoint = resolveExports(pkgJSONData, depId, { require: true }); + let entryPoint = resolveExports(pkgJSON, depId, { require: true }) || ''; if (!entryPoint) { - entryPoint = pkgJSONData['main']; + entryPoint = pkgJSON['main'] || 'index.js'; } - const entryPointPath = path.join(dir, entryPoint); + const entryPointPath = path.join(pkgDir, entryPoint); return entryPointPath; } -function resolvePackageData( - id: string, - rootDir: string, -): PackageData { - // Find the actual package name. For examples: @ice/runtime/server -> @ice/runtime - const idSplits = id.split('/'); - const pkgId = idSplits.slice(0, 2).join('/'); - const packageJSONPath = resolve.sync(`${pkgId}/package.json`, { - basedir: rootDir, - paths: [], - preserveSymlinks: false, - }); - const packageJSONData = fse.readJSONSync(packageJSONPath); - const pkgDir = path.dirname(packageJSONPath); - return { - data: packageJSONData, - dir: pkgDir, - }; -} -function createDepsMetadata(depsInfo: Record<string, string>, taskConfig: Config): DepsMetaData { +function createDepsMetadata(depsInfo: Record<string, DepScanData>, taskConfig: Config): DepsMetaData { const hash = getDepHash(depsInfo, taskConfig); return { hash, @@ -148,7 +123,7 @@ function createDepsMetadata(depsInfo: Record<string, string>, taskConfig: Config }; } -function getDepHash(depsInfo: Record<string, string>, taskConfig: Config) { +function getDepHash(depsInfo: Record<string, DepScanData>, taskConfig: Config) { let content = JSON.stringify(depsInfo) + JSON.stringify(taskConfig); return getHash(content); } diff --git a/packages/ice/src/service/serverCompiler.ts b/packages/ice/src/service/serverCompiler.ts index dfd264c2a..bb54b3ae4 100644 --- a/packages/ice/src/service/serverCompiler.ts +++ b/packages/ice/src/service/serverCompiler.ts @@ -6,17 +6,22 @@ import consola from 'consola'; import esbuild from 'esbuild'; import type { Config, UserConfig } from '@ice/types'; import type { ServerCompiler } from '@ice/types/esm/plugin.js'; +import lodash from '@ice/bundles/compiled/lodash/index.js'; import type { TaskConfig } from 'build-scripts'; import { getCompilerPlugins } from '@ice/webpack-config'; import escapeLocalIdent from '../utils/escapeLocalIdent.js'; import cssModulesPlugin from '../esbuild/cssModules.js'; import aliasPlugin from '../esbuild/alias.js'; import createAssetsPlugin from '../esbuild/assets.js'; -import { ASSETS_MANIFEST, CACHE_DIR, SERVER_ENTRY, SERVER_OUTPUT_DIR } from '../constant.js'; +import { ASSETS_MANIFEST, CACHE_DIR, SERVER_OUTPUT_DIR } from '../constant.js'; import emptyCSSPlugin from '../esbuild/emptyCSS.js'; -import createDepRedirectPlugin from '../esbuild/depRedirect.js'; +import transformImportPlugin from '../esbuild/transformImport.js'; +import transformPipePlugin from '../esbuild/transformPipe.js'; import isExternalBuiltinDep from '../utils/isExternalBuiltinDep.js'; +import getServerEntry from '../utils/getServerEntry.js'; +import type { DepScanData } from '../esbuild/scan.js'; import { scanImports } from './analyze.js'; +import type { DepsMetaData } from './preBundleCJSDeps.js'; import preBundleCJSDeps from './preBundleCJSDeps.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -26,14 +31,18 @@ interface Options { task: TaskConfig<Config>; command: string; server: UserConfig['server']; + syntaxFeatures: UserConfig['syntaxFeatures']; } +const { merge } = lodash; export function createServerCompiler(options: Options) { - const { task, rootDir, command, server } = options; + const { task, rootDir, command, server, syntaxFeatures } = options; - const alias = (task.config?.alias || {}) as Record<string, string | false>; + const alias = task.config?.alias || {}; + const externals = task.config?.externals || {}; const assetsManifest = path.join(rootDir, ASSETS_MANIFEST); const define = task.config?.define || {}; + const sourceMap = task.config?.sourceMap; const dev = command === 'start'; const defineVars = {}; @@ -42,31 +51,51 @@ export function createServerCompiler(options: Options) { defineVars[key] = JSON.stringify(define[key]); }); - // get runtime variable for server build - const runtimeDefineVars = {}; - Object.keys(process.env).forEach((key) => { - if (/^ICE_CORE_/i.test(key)) { - // in server.entry - runtimeDefineVars[`__process.env.${key}__`] = JSON.stringify(process.env[key]); - } else if (/^ICE_/i.test(key)) { - runtimeDefineVars[`process.env.${key}`] = JSON.stringify(process.env[key]); - } - }); - - const serverCompiler: ServerCompiler = async (buildOptions, { preBundle, swc: swcOptions } = {}) => { - let depsMetadata; - if (preBundle) { - depsMetadata = await createDepsMetadata({ task, rootDir }); - } - + const serverCompiler: ServerCompiler = async (customBuildOptions, { + preBundle, + swc, + externalDependencies, + transformEnv = true, + } = {}) => { + let depsMetadata: DepsMetaData; + let swcOptions = merge({}, { + // Only get the `compilationConfig` from task config. + compilationConfig: { + ...(task.config?.swcOptions?.compilationConfig || {}), + // Force inline when use swc as a transformer. + sourceMaps: sourceMap && 'inline', + }, + }, swc); + const enableSyntaxFeatures = syntaxFeatures && Object.keys(syntaxFeatures).some(key => syntaxFeatures[key]); const transformPlugins = getCompilerPlugins({ ...task.config, fastRefresh: false, swcOptions, }, 'esbuild'); - const startTime = new Date().getTime(); - consola.debug('[esbuild]', `start compile for: ${buildOptions.entryPoints}`); + if (preBundle) { + depsMetadata = await createDepsMetadata({ + task, + rootDir, + // Pass transformPlugins only if syntaxFeatures is enabled + plugins: enableSyntaxFeatures ? [ + transformPipePlugin({ + plugins: transformPlugins, + }), + ] : [], + }); + } + // get runtime variable for server build + const runtimeDefineVars = {}; + Object.keys(process.env).forEach((key) => { + // Do not transform env when bundle client side code. + if (/^ICE_CORE_/i.test(key) && transformEnv) { + // in server.entry + runtimeDefineVars[`__process.env.${key}__`] = JSON.stringify(process.env[key]); + } else if (/^ICE_/i.test(key)) { + runtimeDefineVars[`process.env.${key}`] = JSON.stringify(process.env[key]); + } + }); const define = { // ref: https://github.com/evanw/esbuild/blob/master/CHANGELOG.md#01117 // in esm, this in the global should be undefined. Set the following config to avoid warning @@ -74,25 +103,29 @@ export function createServerCompiler(options: Options) { ...defineVars, ...runtimeDefineVars, }; + const format = customBuildOptions?.format || 'esm'; - const esbuildResult = await esbuild.build({ + let buildOptions: esbuild.BuildOptions = { bundle: true, - format: 'esm', + format, target: 'node12.20.0', // enable JSX syntax in .js files by default for compatible with migrate project // while it is not recommended loader: { '.js': 'jsx' }, inject: [path.resolve(__dirname, '../polyfills/react.js')], - ...buildOptions, + sourcemap: typeof sourceMap === 'boolean' + // Transform sourceMap for esbuild. + ? sourceMap : (sourceMap.includes('inline') ? 'inline' : !!sourceMap), + ...customBuildOptions, define, + external: Object.keys(externals), plugins: [ - ...(buildOptions.plugins || []), + ...(customBuildOptions.plugins || []), emptyCSSPlugin(), - dev && preBundle && createDepRedirectPlugin(depsMetadata), aliasPlugin({ alias, - serverBundle: server.bundle, - format: buildOptions?.format || 'esm', + externalDependencies: externalDependencies ?? !server.bundle, + format, }), cssModulesPlugin({ extract: false, @@ -103,18 +136,44 @@ export function createServerCompiler(options: Options) { }, }), fs.existsSync(assetsManifest) && createAssetsPlugin(assetsManifest, rootDir), - ...transformPlugins, + transformPipePlugin({ + plugins: [ + ...transformPlugins, + // Plugin transformImportPlugin need after transformPlugins in case of it has onLoad lifecycle. + dev && preBundle && transformImportPlugin(depsMetadata), + ].filter(Boolean), + }), ].filter(Boolean), - }); - consola.debug('[esbuild]', `time cost: ${new Date().getTime() - startTime}ms`); - const esm = server?.format === 'esm'; - const outJSExtension = esm ? '.mjs' : '.cjs'; - const serverEntry = path.join(rootDir, task.config.outputDir, SERVER_OUTPUT_DIR, `index${outJSExtension}`); - - return { - ...esbuildResult, - serverEntry, + }; + if (typeof task.config?.server?.buildOptions === 'function') { + buildOptions = task.config.server.buildOptions(buildOptions); + } + + const startTime = new Date().getTime(); + consola.debug('[esbuild]', `start compile for: ${buildOptions.entryPoints}`); + + try { + const esbuildResult = await esbuild.build(buildOptions); + + consola.debug('[esbuild]', `time cost: ${new Date().getTime() - startTime}ms`); + + const esm = server?.format === 'esm'; + const outJSExtension = esm ? '.mjs' : '.cjs'; + const serverEntry = path.join(rootDir, task.config.outputDir, SERVER_OUTPUT_DIR, `index${outJSExtension}`); + + return { + ...esbuildResult, + serverEntry, + }; + } catch (error) { + consola.error('Server compile error.', `\nEntryPoints: ${JSON.stringify(buildOptions.entryPoints)}`); + consola.debug(buildOptions); + consola.debug(error); + return { + error: error as Error, + }; + } }; return serverCompiler; } @@ -122,19 +181,21 @@ export function createServerCompiler(options: Options) { interface CreateDepsMetadataOptions { rootDir: string; task: TaskConfig<Config>; + plugins: esbuild.Plugin[]; } /** * Create dependencies metadata only when server entry is bundled to esm. */ -async function createDepsMetadata({ rootDir, task }: CreateDepsMetadataOptions) { - const serverEntry = path.join(rootDir, SERVER_ENTRY); +async function createDepsMetadata({ rootDir, task, plugins }: CreateDepsMetadataOptions) { + const serverEntry = getServerEntry(rootDir, task.config?.server?.entry); const deps = await scanImports([serverEntry], { rootDir, alias: (task.config?.alias || {}) as Record<string, string | false>, + plugins, }); - function filterPreBundleDeps(deps: Record<string, string>) { + function filterPreBundleDeps(deps: Record<string, DepScanData>) { const preBundleDepsInfo = {}; for (const dep in deps) { if (!isExternalBuiltinDep(dep)) { @@ -149,9 +210,9 @@ async function createDepsMetadata({ rootDir, task }: CreateDepsMetadataOptions) const cacheDir = path.join(rootDir, CACHE_DIR); const ret = await preBundleCJSDeps({ depsInfo: preBundleDepsInfo, - rootDir, cacheDir, taskConfig: task.config, + plugins, }); return ret.metadata; diff --git a/packages/ice/src/service/webpackCompiler.ts b/packages/ice/src/service/webpackCompiler.ts index c295d9df2..9a0e151b4 100644 --- a/packages/ice/src/service/webpackCompiler.ts +++ b/packages/ice/src/service/webpackCompiler.ts @@ -5,10 +5,11 @@ import chalk from 'chalk'; import type { CommandArgs, TaskConfig } from 'build-scripts'; import type { Compiler, Configuration } from 'webpack'; import type { Configuration as DevServerConfiguration } from 'webpack-dev-server'; -import type { Urls, ServerCompiler } from '@ice/types/esm/plugin.js'; +import type { Urls, ServerCompiler, GetAppConfig, GetRoutesConfig } from '@ice/types/esm/plugin.js'; import type { Config } from '@ice/types'; import formatWebpackMessages from '../utils/formatWebpackMessages.js'; import openBrowser from '../utils/openBrowser.js'; +import DataLoaderPlugin from '../webpack/DataLoaderPlugin.js'; type WebpackConfig = Configuration & { devServer?: DevServerConfiguration }; async function webpackCompiler(options: { @@ -19,9 +20,13 @@ async function webpackCompiler(options: { applyHook: (key: string, opts?: {}) => Promise<void>; rootDir: string; urls?: Urls; - serverCompiler: ServerCompiler; spinner: ora.Ora; devPath?: string; + hooksAPI: { + serverCompiler: ServerCompiler; + getAppConfig: GetAppConfig; + getRoutesConfig: GetRoutesConfig; + }; }) { const { taskConfigs, @@ -29,18 +34,23 @@ async function webpackCompiler(options: { applyHook, command, commandArgs, - serverCompiler, + hooksAPI, webpackConfigs, spinner, devPath, + rootDir, } = options; + const { serverCompiler } = hooksAPI; await applyHook(`before.${command}.run`, { urls, commandArgs, taskConfigs, webpackConfigs, - serverCompiler, + ...hooksAPI, }); + // Add webpack plugin of data-loader + webpackConfigs[0].plugins.push(new DataLoaderPlugin({ serverCompiler, rootDir })); + // Add default plugins for spinner webpackConfigs[0].plugins.push((compiler: Compiler) => { compiler.hooks.beforeCompile.tap('spinner', () => { @@ -55,8 +65,7 @@ async function webpackCompiler(options: { // @ts-expect-error ignore error with different webpack referer compiler = webpack(webpackConfigs as Configuration); } catch (err) { - consola.error('Failed to compile.'); - consola.log(''); + consola.error('Webpack compile error.'); consola.error(err.message || err); } @@ -78,13 +87,12 @@ async function webpackCompiler(options: { if (messages.errors.length > 1) { messages.errors.length = 1; } - consola.error('Failed to compile.\n'); - consola.error(messages.errors.join('\n\n')); - consola.log(stats.toString()); + consola.error('Failed to compile.'); + console.error(messages.errors.join('\n')); return; } else if (messages.warnings.length) { - consola.warn('Compiled with warnings.\n'); - consola.warn(messages.warnings.join('\n\n')); + consola.warn('Compiled with warnings.'); + consola.warn(messages.warnings.join('\n')); } if (command === 'start') { if (isSuccessful && isFirstCompile) { @@ -94,13 +102,13 @@ async function webpackCompiler(options: { logoutMessage += `\n - IDE server: https://${process.env.WORKSPACE_UUID}-${commandArgs.port}.${process.env.WORKSPACE_HOST}${devPath}`; } else { logoutMessage += `\n - - Local : ${chalk.underline.white(urls.localUrlForBrowser)}${devPath} - - Network: ${chalk.underline.white(urls.lanUrlForTerminal)}${devPath}`; + - Local : ${chalk.underline.white(`${urls.localUrlForBrowser}${devPath}`)} + - Network: ${chalk.underline.white(`${urls.lanUrlForTerminal}${devPath}`)}`; } consola.log(`${logoutMessage}\n`); if (commandArgs.open) { - openBrowser(urls.localUrlForBrowser); + openBrowser(`${urls.localUrlForBrowser}${devPath}`); } } // compiler.hooks.done is AsyncSeriesHook which does not support async function @@ -111,7 +119,7 @@ async function webpackCompiler(options: { urls, messages, taskConfigs, - serverCompiler, + ...hooksAPI, }); } diff --git a/packages/ice/src/tasks/web/data-loader.ts b/packages/ice/src/tasks/web/data-loader.ts deleted file mode 100644 index aac7f76b9..000000000 --- a/packages/ice/src/tasks/web/data-loader.ts +++ /dev/null @@ -1,36 +0,0 @@ -import * as path from 'path'; -import type { Config } from '@ice/types'; -import { CACHE_DIR, DATA_LOADER_ENTRY, RUNTIME_TMP_DIR } from '../../constant.js'; - -const getTask = ({ rootDir, command }): Config => { - // basic task config of data-loader - return { - entry: { - 'data-loader': path.join(rootDir, DATA_LOADER_ENTRY), - }, - mode: command === 'start' ? 'development' : 'production', - sourceMap: command === 'start' ? 'cheap-module-source-map' : false, - cacheDir: path.join(rootDir, CACHE_DIR), - alias: { - ice: path.join(rootDir, RUNTIME_TMP_DIR, 'index.ts'), - '@': path.join(rootDir, 'src'), - // set alias for webpack/hot while webpack has been prepacked - 'webpack/hot': '@ice/bundles/compiled/webpack/hot', - }, - swcOptions: { - jsxTransform: true, - removeExportExprs: ['default', 'getConfig', 'getServerData', 'getStaticData'], - }, - splitChunks: false, - // enable concatenateModules will tree shaking unused `react/react-dom` in dev mod. - concatenateModules: true, - devServer: { - hot: false, - client: false, - }, - // always need reload when data loader is changed - fastRefresh: false, - }; -}; - -export default getTask; diff --git a/packages/ice/src/tasks/web/index.ts b/packages/ice/src/tasks/web/index.ts index 1b523cc5a..92e07a1bf 100644 --- a/packages/ice/src/tasks/web/index.ts +++ b/packages/ice/src/tasks/web/index.ts @@ -16,9 +16,9 @@ const getWebTask = ({ rootDir, command }): Config => { 'webpack/hot': '@ice/bundles/compiled/webpack/hot', }, swcOptions: { - jsxTransform: true, // getData is built by data-loader removeExportExprs: ['getData', 'getServerData', 'getStaticData'], + keepPlatform: 'web', }, assetsManifest: true, fastRefresh: command === 'start', diff --git a/packages/plugin-pha/src/removeTopLevelCode.ts b/packages/ice/src/utils/babelPluginRemoveCode.ts similarity index 80% rename from packages/plugin-pha/src/removeTopLevelCode.ts rename to packages/ice/src/utils/babelPluginRemoveCode.ts index 023a84ba5..64f59f679 100644 --- a/packages/plugin-pha/src/removeTopLevelCode.ts +++ b/packages/ice/src/utils/babelPluginRemoveCode.ts @@ -47,27 +47,36 @@ const removeUnreferencedCode = (nodePath: NodePath<t.Program>) => { } }; -const removeTopLevelCode = () => { +const keepExportCode = (identifier: t.Identifier, keepExports: string[]) => { + return keepExports.some((exportString) => { + return t.isIdentifier(identifier, { name: exportString }); + }); +}; + +const removeTopLevelCode = (keepExports: string[] = []) => { return { ExportNamedDeclaration: { enter(nodePath: NodePath<t.ExportNamedDeclaration>) { const { node } = nodePath; - // export function getConfig() {} - const isFunctionExport = t.isFunctionDeclaration(node.declaration) && t.isIdentifier(node.declaration.id, { name: 'getConfig' }); - // export const getConfig = () => {} - const isVariableExport = t.isVariableDeclaration(node.declaration) && t.isIdentifier(node.declaration.declarations![0]?.id, { name: 'getConfig' }); - // export { getConfig }; + // Exp: export function getConfig() {} + const isFunctionExport = t.isFunctionDeclaration(node.declaration) && + keepExportCode(node.declaration.id, keepExports); + // Exp: export const getConfig = () => {} + const isVariableExport = t.isVariableDeclaration(node.declaration) && + keepExportCode(node.declaration.declarations![0]?.id as t.Identifier, keepExports); + // Exp: export { getConfig }; if (node.specifiers && node.specifiers.length > 0) { nodePath.traverse({ ExportSpecifier(nodePath: NodePath<t.ExportSpecifier>) { - if (!t.isIdentifier(nodePath.node.exported, { name: 'getConfig' })) { + if (!keepExportCode(nodePath.node.exported as t.Identifier, keepExports)) { nodePath.remove(); } }, }); - node.specifiers = node.specifiers.filter(specifier => t.isIdentifier(specifier.exported, { name: 'getConfig' })); + node.specifiers = node.specifiers.filter(specifier => + keepExportCode(specifier.exported as t.Identifier, keepExports)); } else if (!isFunctionExport && !isVariableExport) { - // Remove named export expect 'getConfig'. + // Remove named export expect defined in keepExports. nodePath.remove(); } }, @@ -75,7 +84,9 @@ const removeTopLevelCode = () => { ExportDefaultDeclaration: { enter(nodePath: NodePath<t.ExportDefaultDeclaration>) { // Remove default export declaration. - nodePath.remove(); + if (!keepExports.includes('default')) { + nodePath.remove(); + } }, }, ExpressionStatement: { diff --git a/packages/ice/src/utils/generateHTML.ts b/packages/ice/src/utils/generateHTML.ts index 067b34bb6..8a7e5731b 100644 --- a/packages/ice/src/utils/generateHTML.ts +++ b/packages/ice/src/utils/generateHTML.ts @@ -49,6 +49,7 @@ export default async function generateHTML(options: Options) { const { value: html } = await serverEntry.renderToHTML(serverContext, { renderMode, documentOnly, + routePath, serverOnlyBasename: '/', }); diff --git a/packages/ice/src/utils/getRouterBasename.ts b/packages/ice/src/utils/getRouterBasename.ts new file mode 100644 index 000000000..32482fdfa --- /dev/null +++ b/packages/ice/src/utils/getRouterBasename.ts @@ -0,0 +1,9 @@ +import type { AppConfig } from '@ice/runtime'; +import type { Config } from '@ice/types'; +import type { TaskConfig } from 'build-scripts'; + +const getRouterBasename = (taskConfig: TaskConfig<Config>, appConfig: AppConfig) => { + return taskConfig?.config?.basename || appConfig?.router?.basename; +}; + +export default getRouterBasename; diff --git a/packages/ice/src/utils/getRuntimeModules.ts b/packages/ice/src/utils/getRuntimeModules.ts index 4a45bd1b0..45a8d3359 100644 --- a/packages/ice/src/utils/getRuntimeModules.ts +++ b/packages/ice/src/utils/getRuntimeModules.ts @@ -31,7 +31,7 @@ function getRuntimeModules(plugins: Array<PluginInfo<any, ExtendsPluginAPI>>) { name: pkgInfo.name as string, }; } catch (error) { - consola.error(`ERROR: fail to load package.json of plugin ${path.basename(packageDir)}`); + consola.error(`Failed to load package.json of plugin ${path.basename(packageDir)}`); } } else { consola.warn(`runtime is not exist in ${name}`); diff --git a/packages/ice/src/utils/getServerEntry.ts b/packages/ice/src/utils/getServerEntry.ts new file mode 100644 index 000000000..b1bb0f8be --- /dev/null +++ b/packages/ice/src/utils/getServerEntry.ts @@ -0,0 +1,15 @@ +import * as path from 'path'; +import fg from 'fast-glob'; +import { SERVER_ENTRY } from '../constant.js'; + +export default function getServerEntry(rootDir: string, customServerEntry?: string) { + // Check entry.server.ts. + let entryFile = fg.sync('entry.server.{tsx,ts,jsx.js}', { + cwd: path.join(rootDir, 'src'), + absolute: true, + })[0]; + if (!entryFile) { + entryFile = customServerEntry || path.join(rootDir, SERVER_ENTRY); + } + return entryFile; +} diff --git a/packages/ice/src/utils/hash.ts b/packages/ice/src/utils/hash.ts index a45a0c6dc..0837e4143 100644 --- a/packages/ice/src/utils/hash.ts +++ b/packages/ice/src/utils/hash.ts @@ -1,10 +1,17 @@ +import * as path from 'path'; import * as fs from 'fs'; import { createHash } from 'crypto'; +import fg from 'fast-glob'; export async function getFileHash(file: string): Promise<string> { + let filePath = file; + if (!path.extname(filePath)) { + const patterns = [`${filePath}.{js,ts,jsx,tsx}`]; + filePath = fg.sync(patterns)[0]; + } return new Promise((resolve, reject) => { const hash = createHash('sha256'); - fs.createReadStream(file) + fs.createReadStream(filePath) .on('error', (err) => reject(err)) .on('data', (data) => hash.update(data)) .on('close', () => resolve(hash.digest('hex'))); diff --git a/packages/ice/src/utils/persistentCache.ts b/packages/ice/src/utils/persistentCache.ts index 959a1b10c..795c03428 100644 --- a/packages/ice/src/utils/persistentCache.ts +++ b/packages/ice/src/utils/persistentCache.ts @@ -3,12 +3,12 @@ import cacache from '@ice/bundles/compiled/cacache/index.js'; const CACHE_PATH = 'node_modules/.cache/route'; -export function getRouteCache(rootDir: string, routeId: string) { +export function getCache(rootDir: string, id: string) { const cachePath = path.join(rootDir, CACHE_PATH); - return cacache.get(cachePath, routeId).then((cache) => JSON.parse(cache.data.toString('utf-8'))); + return cacache.get(cachePath, id).then((cache) => JSON.parse(cache.data.toString('utf-8'))); } -export function setRouteCache(rootDir: string, routeId: string, data: any) { +export function setCache(rootDir: string, id: string, data: any) { const cachePath = path.join(rootDir, CACHE_PATH); - return cacache.put(cachePath, routeId, JSON.stringify(data)); + return cacache.put(cachePath, id, JSON.stringify(data)); } \ No newline at end of file diff --git a/packages/ice/src/utils/renderExportsTemplate.ts b/packages/ice/src/utils/renderExportsTemplate.ts new file mode 100644 index 000000000..b5e5cc24c --- /dev/null +++ b/packages/ice/src/utils/renderExportsTemplate.ts @@ -0,0 +1,38 @@ +import * as path from 'path'; +import fse from 'fs-extra'; +import type Generator from '../service/runtimeGenerator.js'; + +type RenderData = { + loaders: string; + routesConfig: string; +} & Record<string, any>; + +function renderExportsTemplate( + renderData: RenderData, + addRenderFile: Generator['addRenderFile'], + renderOptions: { + rootDir: string; + runtimeDir: string; + templateDir: string; + }, +) { + const renderList: [string, boolean][] = [ + ['data-loader.ts.ejs', !!renderData.loaders || renderData.hasExportAppData], + ['routes-config.ts.ejs', !!renderData.routesConfig], + ]; + const { rootDir, runtimeDir, templateDir } = renderOptions; + renderList.forEach(([filePath, needRender]) => { + const targetFilePath = path.join(rootDir, runtimeDir, filePath.replace('.ejs', '')); + if (needRender) { + addRenderFile( + path.join(templateDir, filePath), + targetFilePath, + renderData, + ); + } else if (fse.existsSync(targetFilePath)) { + fse.removeSync(targetFilePath); + } + }); +} + +export default renderExportsTemplate; \ No newline at end of file diff --git a/packages/ice/src/utils/runtimeEnv.ts b/packages/ice/src/utils/runtimeEnv.ts index dadb13cb7..6cf4ec482 100644 --- a/packages/ice/src/utils/runtimeEnv.ts +++ b/packages/ice/src/utils/runtimeEnv.ts @@ -2,7 +2,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as dotenv from 'dotenv'; import { expand as dotenvExpand } from 'dotenv-expand'; -import type { CommandArgs, CommandName } from 'build-scripts'; +import type { CommandArgs } from 'build-scripts'; import type { AppConfig } from '@ice/types'; export interface Envs { @@ -12,9 +12,11 @@ interface EnvOptions { disableRouter: boolean; } -export async function initProcessEnv( +/** + * Set env params in .env file and built-in env params to process.env. + */ +export async function setEnv( rootDir: string, - command: CommandName, commandArgs: CommandArgs, ): Promise<void> { const { mode } = commandArgs; @@ -41,15 +43,6 @@ export async function initProcessEnv( process.env.ICE_CORE_MODE = mode; process.env.ICE_CORE_DEV_PORT = commandArgs.port; - if (process.env.TEST || command === 'test') { - process.env.NODE_ENV = 'test'; - } else if (command === 'start') { - process.env.NODE_ENV = 'development'; - } else { - // build - process.env.NODE_ENV = 'production'; - } - // set runtime initial env process.env.ICE_CORE_ROUTER = 'true'; process.env.ICE_CORE_ERROR_BOUNDARY = 'true'; diff --git a/packages/ice/src/webpack/DataLoaderPlugin.ts b/packages/ice/src/webpack/DataLoaderPlugin.ts new file mode 100644 index 000000000..ec0efa5de --- /dev/null +++ b/packages/ice/src/webpack/DataLoaderPlugin.ts @@ -0,0 +1,55 @@ +import * as path from 'path'; +import fse from 'fs-extra'; +import type { ServerCompiler } from '@ice/types/esm/plugin.js'; +import type { Compiler } from 'webpack'; +import webpack from '@ice/bundles/compiled/webpack/index.js'; +import { RUNTIME_TMP_DIR } from '../constant.js'; + +const pluginName = 'DataLoaderPlugin'; +const { RawSource } = webpack.sources; + +export default class DataLoaderPlugin { + private serverCompiler: ServerCompiler; + private rootDir: string; + public constructor(options: { + serverCompiler: ServerCompiler; + rootDir: string; + }) { + const { serverCompiler, rootDir } = options; + this.serverCompiler = serverCompiler; + this.rootDir = rootDir; + } + + public apply(compiler: Compiler) { + compiler.hooks.thisCompilation.tap(pluginName, (compilation) => { + compilation.hooks.processAssets.tapAsync({ + name: pluginName, + stage: webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, async (_, callback) => { + // Check file data-loader.ts if it is exists. + const filePath = path.join(this.rootDir, RUNTIME_TMP_DIR, 'data-loader.ts'); + if (fse.existsSync(filePath)) { + const { outputFiles } = await this.serverCompiler({ + // Code will be transformed by @swc/core reset target to esnext make modern js syntax do not transformed. + target: 'esnext', + entryPoints: [filePath], + write: false, + inject: [], + }, { + swc: { + removeExportExprs: ['default', 'getConfig', 'getServerData', 'getStaticData'], + keepPlatform: 'web', + }, + preBundle: false, + externalDependencies: false, + transformEnv: false, + }); + compilation.emitAsset('js/data-loader.js', new RawSource(new TextDecoder('utf-8').decode(outputFiles[0].contents))); + } else { + compilation.deleteAsset('js/data-loader.js'); + } + callback(); + }); + }); + } +} diff --git a/packages/ice/src/webpack/ReCompilePlugin.ts b/packages/ice/src/webpack/ReCompilePlugin.ts new file mode 100644 index 000000000..fe9012eaf --- /dev/null +++ b/packages/ice/src/webpack/ReCompilePlugin.ts @@ -0,0 +1,32 @@ +import type { Compiler } from 'webpack'; + +type CheckModifiedFiles = (modifiedFiles: string[]) => boolean; +type ReCompile = (compileKey: string) => void; + +const pluginName = 'ReCompilePlugin'; +export default class ReCompilePlugin { + private reCompile: ReCompile; + private checkModifiedFiles: CheckModifiedFiles; + private needRecompile: boolean; + + constructor(reCompile: ReCompile, checkModifiedFiles: CheckModifiedFiles) { + this.reCompile = reCompile; + this.checkModifiedFiles = checkModifiedFiles; + this.needRecompile = false; + } + + apply(compiler: Compiler) { + compiler.hooks.watchRun.tap(pluginName, (compilation: Compiler) => { + this.needRecompile = false; + if (compilation.modifiedFiles) { + this.needRecompile = this.checkModifiedFiles(Array.from(compilation.modifiedFiles)); + } + }); + compiler.hooks.emit.tap(pluginName, () => { + if (this.needRecompile) { + // Call re compile at lifecycle of emit. + this.reCompile('getConfig'); + } + }); + } +} \ No newline at end of file diff --git a/packages/ice/templates/404.tsx b/packages/ice/templates/core/404.tsx similarity index 100% rename from packages/ice/templates/404.tsx rename to packages/ice/templates/core/404.tsx diff --git a/packages/ice/templates/entry.client.ts.ejs b/packages/ice/templates/core/entry.client.ts.ejs similarity index 91% rename from packages/ice/templates/entry.client.ts.ejs rename to packages/ice/templates/core/entry.client.ts.ejs index 3412b1ca9..8e1c90285 100644 --- a/packages/ice/templates/entry.client.ts.ejs +++ b/packages/ice/templates/core/entry.client.ts.ejs @@ -16,4 +16,5 @@ runClientApp({ Document, basename: getRouterBasename(), hydrate: <%- hydrate %>, + memoryRouter: <%- memoryRouter || false %>, }); diff --git a/packages/ice/templates/entry.server.ts.ejs b/packages/ice/templates/core/entry.server.ts.ejs similarity index 89% rename from packages/ice/templates/entry.server.ts.ejs rename to packages/ice/templates/core/entry.server.ts.ejs index b754bf663..0b8cedcb9 100644 --- a/packages/ice/templates/entry.server.ts.ejs +++ b/packages/ice/templates/core/entry.server.ts.ejs @@ -25,10 +25,12 @@ interface RenderOptions { renderMode?: RenderMode; basename?: string; serverOnlyBasename?: string; + routePath?: string; + disableFallback?: boolean; } export async function renderToHTML(requestContext, options: RenderOptions = {}) { - const { documentOnly, renderMode = 'SSR', basename, serverOnlyBasename } = options; + const { documentOnly, renderMode = 'SSR', basename, serverOnlyBasename, routePath, disableFallback } = options; setRuntimeEnv(renderMode); return await runtime.renderToHTML(requestContext, { @@ -41,11 +43,13 @@ export async function renderToHTML(requestContext, options: RenderOptions = {}) basename: basename || getRouterBasename(), documentOnly, renderMode, + routePath, + disableFallback, }); } export async function renderToResponse(requestContext, options: RenderOptions = {}) { - const { documentOnly, renderMode = 'SSR', basename, serverOnlyBasename } = options; + const { documentOnly, renderMode = 'SSR', basename, serverOnlyBasename, disableFallback } = options; setRuntimeEnv(options); runtime.renderToResponse(requestContext, { @@ -58,5 +62,6 @@ export async function renderToResponse(requestContext, options: RenderOptions = basename: basename || getRouterBasename(), documentOnly, renderMode, + disableFallback, }); } diff --git a/packages/ice/templates/env.server.ts.ejs b/packages/ice/templates/core/env.server.ts.ejs similarity index 100% rename from packages/ice/templates/env.server.ts.ejs rename to packages/ice/templates/core/env.server.ts.ejs diff --git a/packages/ice/templates/index.ts.ejs b/packages/ice/templates/core/index.ts.ejs similarity index 98% rename from packages/ice/templates/index.ts.ejs rename to packages/ice/templates/core/index.ts.ejs index bc0c2f37a..8dc432a46 100644 --- a/packages/ice/templates/index.ts.ejs +++ b/packages/ice/templates/core/index.ts.ejs @@ -44,6 +44,7 @@ export { export { defineAppConfig, + useAppData, useData, useConfig, Meta, diff --git a/packages/ice/templates/route-manifest.json.ejs b/packages/ice/templates/core/route-manifest.json.ejs similarity index 100% rename from packages/ice/templates/route-manifest.json.ejs rename to packages/ice/templates/core/route-manifest.json.ejs diff --git a/packages/ice/templates/routes.ts.ejs b/packages/ice/templates/core/routes.ts.ejs similarity index 100% rename from packages/ice/templates/routes.ts.ejs rename to packages/ice/templates/core/routes.ts.ejs diff --git a/packages/ice/templates/runtimeModules.ts.ejs b/packages/ice/templates/core/runtimeModules.ts.ejs similarity index 100% rename from packages/ice/templates/runtimeModules.ts.ejs rename to packages/ice/templates/core/runtimeModules.ts.ejs diff --git a/packages/ice/templates/types.ts.ejs b/packages/ice/templates/core/types.ts.ejs similarity index 71% rename from packages/ice/templates/types.ts.ejs rename to packages/ice/templates/core/types.ts.ejs index ad18deb7f..787cdc31c 100644 --- a/packages/ice/templates/types.ts.ejs +++ b/packages/ice/templates/core/types.ts.ejs @@ -1,4 +1,4 @@ -import type { AppConfig as DefaultAppConfig } from '@ice/runtime'; +import type { AppConfig as DefaultAppConfig, GetAppData, AppData } from '@ice/runtime'; <%- configTypes.imports -%> @@ -13,3 +13,8 @@ export type AppConfig = ExtendsAppConfig; <% } else { -%> export type AppConfig = DefaultAppConfig; <% } -%> + +export { + GetAppData, + AppData +} \ No newline at end of file diff --git a/packages/ice/templates/data-loader.ts.ejs b/packages/ice/templates/data-loader.ts.ejs deleted file mode 100644 index 6ca96eb1e..000000000 --- a/packages/ice/templates/data-loader.ts.ejs +++ /dev/null @@ -1,4 +0,0 @@ -import { dataLoader } from '@ice/runtime'; -<%- loaders %> - -dataLoader.init(loaders); diff --git a/packages/ice/templates/exports/data-loader.ts.ejs b/packages/ice/templates/exports/data-loader.ts.ejs new file mode 100644 index 000000000..3a64cd860 --- /dev/null +++ b/packages/ice/templates/exports/data-loader.ts.ejs @@ -0,0 +1,7 @@ +import { dataLoader } from '@ice/runtime'; +<% if(hasExportAppData) {-%>import { getAppData } from '@/app';<% } -%> + +<%- loaders %> +<% if(hasExportAppData) {-%>loaders['__app'] = getAppData;<% } -%> + +dataLoader.init(loaders); diff --git a/packages/ice/templates/routes-config.ts.ejs b/packages/ice/templates/exports/routes-config.ts.ejs similarity index 100% rename from packages/ice/templates/routes-config.ts.ejs rename to packages/ice/templates/exports/routes-config.ts.ejs diff --git a/packages/ice/tests/fixtures/preAnalyze/app.ts b/packages/ice/tests/fixtures/preAnalyze/app.ts index 9b1618981..3f2ced768 100644 --- a/packages/ice/tests/fixtures/preAnalyze/app.ts +++ b/packages/ice/tests/fixtures/preAnalyze/app.ts @@ -10,4 +10,4 @@ runApp({ page, } as AppConfig) -export default () => {}; \ No newline at end of file +export default () => { }; \ No newline at end of file diff --git a/packages/ice/tests/fixtures/removeCode/export-default.ts b/packages/ice/tests/fixtures/removeCode/export-default.ts new file mode 100644 index 000000000..6a6879bb7 --- /dev/null +++ b/packages/ice/tests/fixtures/removeCode/export-default.ts @@ -0,0 +1,2 @@ +const a = 1; +export default a; \ No newline at end of file diff --git a/packages/ice/tests/fixtures/removeCode/export-specifier.ts b/packages/ice/tests/fixtures/removeCode/export-specifier.ts new file mode 100644 index 000000000..c159c4366 --- /dev/null +++ b/packages/ice/tests/fixtures/removeCode/export-specifier.ts @@ -0,0 +1,6 @@ +const getConfig = () => {}; +const getData = () => {}; +export { + getConfig, + getData, +}; \ No newline at end of file diff --git a/packages/ice/tests/fixtures/removeCode/export-variable.ts b/packages/ice/tests/fixtures/removeCode/export-variable.ts new file mode 100644 index 000000000..284ec604a --- /dev/null +++ b/packages/ice/tests/fixtures/removeCode/export-variable.ts @@ -0,0 +1,2 @@ +export const getData = () => {}; +export const getConfig = () => {}; \ No newline at end of file diff --git a/packages/ice/tests/fixtures/removeCode/function-exports.ts b/packages/ice/tests/fixtures/removeCode/function-exports.ts new file mode 100644 index 000000000..f5497a5f9 --- /dev/null +++ b/packages/ice/tests/fixtures/removeCode/function-exports.ts @@ -0,0 +1,3 @@ +export default function Bar() {} +export function getConfig() {} +export function getData() {} \ No newline at end of file diff --git a/packages/ice/tests/fixtures/removeCode/if.ts b/packages/ice/tests/fixtures/removeCode/if.ts new file mode 100644 index 000000000..431b4b862 --- /dev/null +++ b/packages/ice/tests/fixtures/removeCode/if.ts @@ -0,0 +1,4 @@ +var a = 1; +if (true) { + a = 2; +} diff --git a/packages/ice/tests/fixtures/removeCode/iife.ts b/packages/ice/tests/fixtures/removeCode/iife.ts new file mode 100644 index 000000000..b23bbb7ea --- /dev/null +++ b/packages/ice/tests/fixtures/removeCode/iife.ts @@ -0,0 +1,5 @@ +function a() {} +a(); +console.log('test', window.a); +const b = []; +b.map(() => {}); \ No newline at end of file diff --git a/packages/ice/tests/fixtures/removeCode/import.ts b/packages/ice/tests/fixtures/removeCode/import.ts new file mode 100644 index 000000000..ab0145261 --- /dev/null +++ b/packages/ice/tests/fixtures/removeCode/import.ts @@ -0,0 +1,8 @@ +import { a, b } from 'test'; +import { a as c } from 'test-a'; +import d from 'test-d'; +import 'test-c'; + +export function getConfig() { + return { a: 1 }; +} \ No newline at end of file diff --git a/packages/ice/tests/fixtures/removeCode/reference.ts b/packages/ice/tests/fixtures/removeCode/reference.ts new file mode 100644 index 000000000..885dc20aa --- /dev/null +++ b/packages/ice/tests/fixtures/removeCode/reference.ts @@ -0,0 +1,5 @@ +import { a, b } from 'test'; + +function test() { + a(); +} \ No newline at end of file diff --git a/packages/ice/tests/fixtures/removeCode/vars.ts b/packages/ice/tests/fixtures/removeCode/vars.ts new file mode 100644 index 000000000..bfb814f71 --- /dev/null +++ b/packages/ice/tests/fixtures/removeCode/vars.ts @@ -0,0 +1,17 @@ +import { a, z } from 'a'; +import b from 'b'; +import c from 'c'; +import d from 'd'; + +const [e, f, ...rest] = a; +const {h, j} = b; +const [x, ...m] = c; +const zz = 'x'; +const {k, l, ...s} = d; + +export function getConfig() { + return { + x, + k, + }; +} \ No newline at end of file diff --git a/packages/ice/tests/fixtures/removeCode/while.ts b/packages/ice/tests/fixtures/removeCode/while.ts new file mode 100644 index 000000000..abc8e1707 --- /dev/null +++ b/packages/ice/tests/fixtures/removeCode/while.ts @@ -0,0 +1,8 @@ +var j = 2; +var i = 2; +while (j < 3) { + j++; +} +do { + i++; +} while (i < 5); \ No newline at end of file diff --git a/packages/ice/tests/fixtures/scan/app.ts b/packages/ice/tests/fixtures/scan/app.ts new file mode 100644 index 000000000..26142768a --- /dev/null +++ b/packages/ice/tests/fixtures/scan/app.ts @@ -0,0 +1,7 @@ +import { defineAppConfig } from '@ice/runtime'; +import { getAppConfig } from '@ice/runtime/client'; +import page from '@/page'; + +console.log(page); +console.log(getAppConfig); +export default defineAppConfig({}); diff --git a/packages/ice/tests/fixtures/scan/page.tsx b/packages/ice/tests/fixtures/scan/page.tsx new file mode 100644 index 000000000..1a97d1b9e --- /dev/null +++ b/packages/ice/tests/fixtures/scan/page.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +// test loop include +import app from './app'; + +app(); + +export default () => (<>test</>); diff --git a/packages/ice/tests/preAnalyze.test.ts b/packages/ice/tests/preAnalyze.test.ts index 668ae2c16..82862f1d1 100644 --- a/packages/ice/tests/preAnalyze.test.ts +++ b/packages/ice/tests/preAnalyze.test.ts @@ -26,6 +26,11 @@ describe('resolveId', () => { const id = resolveId('ice', alias); expect(id).toBe(false); }); + it('alias: { foundnamejs: \'/user/folder\'}; id: foundnamejs', () => { + const alias = { 'foundnamejs': '/user/folder' }; + const id = resolveId('foundnamejs', alias); + expect(id).toBe('/user/folder'); + }); it('alias with relative path', () => { const alias = { ice: 'rax' } as Alias; diff --git a/packages/ice/tests/preBundleCJSDeps.test.ts b/packages/ice/tests/preBundleCJSDeps.test.ts new file mode 100644 index 000000000..611e0db37 --- /dev/null +++ b/packages/ice/tests/preBundleCJSDeps.test.ts @@ -0,0 +1,28 @@ +import { afterAll, expect, it } from 'vitest'; +import * as path from 'path'; +import fse from 'fs-extra'; +import { fileURLToPath } from 'url'; +import preBundleCJSDeps from '../src/service/preBundleCJSDeps'; +import { scanImports } from '../src/service/analyze'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const alias = { '@': path.join(__dirname, './fixtures/scan') }; +const rootDir = path.join(__dirname, './fixtures/scan'); +const cacheDir = path.join(rootDir, '.ice'); + +it('prebundle cjs deps', async () => { + const deps = await scanImports([path.join(__dirname, './fixtures/scan/app.ts')], { alias, rootDir }); + await preBundleCJSDeps({ + depsInfo: deps, + cacheDir, + taskConfig: { mode: 'production' } + }); + + expect(fse.pathExistsSync(path.join(cacheDir, 'deps', 'react.js'))).toBeTruthy(); + expect(fse.pathExistsSync(path.join(cacheDir, 'deps', '@ice_runtime_client.js'))).toBeTruthy(); + expect(fse.pathExistsSync(path.join(cacheDir, 'deps', '@ice_runtime.js'))).toBeTruthy(); +}); + +afterAll(async () => { + await fse.remove(cacheDir); +}); diff --git a/packages/plugin-pha/tests/removeTopLevelCode.test.ts b/packages/ice/tests/removeTopLevelCode.test.ts similarity index 64% rename from packages/plugin-pha/tests/removeTopLevelCode.test.ts rename to packages/ice/tests/removeTopLevelCode.test.ts index 004c8677a..49726f871 100644 --- a/packages/plugin-pha/tests/removeTopLevelCode.test.ts +++ b/packages/ice/tests/removeTopLevelCode.test.ts @@ -5,7 +5,7 @@ import { expect, it, describe } from 'vitest'; import { parse, type ParserOptions } from '@babel/parser'; import traverse from '@babel/traverse' import generate from '@babel/generator'; -import removeTopLevelCodePlugin from '../src/removeTopLevelCode'; +import removeTopLevelCodePlugin from '../src/utils/babelPluginRemoveCode'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -24,58 +24,65 @@ const parserOptions: ParserOptions = { describe('remove top level code', () => { it('remove specifier export', () => { - const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/export-specifier.ts'), 'utf-8'), parserOptions); - traverse(ast, removeTopLevelCodePlugin()); + const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/removeCode/export-specifier.ts'), 'utf-8'), parserOptions); + traverse(ast, removeTopLevelCodePlugin(['getConfig'])); const content = generate(ast).code; expect(content.replace(/\n/g, '').replace(/\s+/g, ' ')).toBe(`const getConfig = () => {};export { getConfig };`); }); it('remove variable export', () => { - const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/export-variable.ts'), 'utf-8'), parserOptions); - traverse(ast, removeTopLevelCodePlugin()); + const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/removeCode/export-variable.ts'), 'utf-8'), parserOptions); + traverse(ast, removeTopLevelCodePlugin(['getConfig'])); const content = generate(ast).code; expect(content.replace(/\n/g, '').replace(/\s+/g, ' ')).toBe(`export const getConfig = () => {};`); }); it('remove function export', () => { - const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/function-exports.ts'), 'utf-8'), parserOptions); - traverse(ast, removeTopLevelCodePlugin()); + const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/removeCode/function-exports.ts'), 'utf-8'), parserOptions); + traverse(ast, removeTopLevelCodePlugin(['getConfig'])); const content = generate(ast).code; expect(content.replace(/\n/g, '').replace(/\s+/g, ' ')).toBe(`export function getConfig() {}`); }); it('remove if statement', () => { - const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/if.ts'), 'utf-8'), parserOptions); - traverse(ast, removeTopLevelCodePlugin()); + const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/removeCode/if.ts'), 'utf-8'), parserOptions); + traverse(ast, removeTopLevelCodePlugin(['getConfig'])); const content = generate(ast).code; expect(content).toBe(''); }); it('remove import statement', () => { - const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/import.ts'), 'utf-8'), parserOptions); - traverse(ast, removeTopLevelCodePlugin()); + const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/removeCode/import.ts'), 'utf-8'), parserOptions); + traverse(ast, removeTopLevelCodePlugin(['getConfig'])); const content = generate(ast).code; expect(content.replace(/\n/g, '').replace(/\s+/g, ' ')).toBe(`export function getConfig() { return { a: 1 };}`); }); it('remove IIFE code', () => { - const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/iife.ts'), 'utf-8'), parserOptions); - traverse(ast, removeTopLevelCodePlugin()); + const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/removeCode/iife.ts'), 'utf-8'), parserOptions); + traverse(ast, removeTopLevelCodePlugin(['getConfig'])); const content = generate(ast).code; expect(content).toBe(''); }); it('remove loop code', () => { - const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/while.ts'), 'utf-8'), parserOptions); - traverse(ast, removeTopLevelCodePlugin()); + const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/removeCode/while.ts'), 'utf-8'), parserOptions); + traverse(ast, removeTopLevelCodePlugin(['getConfig'])); const content = generate(ast).code; expect(content).toBe(''); }); it('remove nested reference code', () => { - const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/reference.ts'), 'utf-8'), parserOptions); - traverse(ast, removeTopLevelCodePlugin()); + const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/removeCode/reference.ts'), 'utf-8'), parserOptions); + traverse(ast, removeTopLevelCodePlugin(['getConfig'])); const content = generate(ast).code; expect(content).toBe(''); }); it('remove variable declaration code', () => { - const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/vars.ts'), 'utf-8'), parserOptions); - traverse(ast, removeTopLevelCodePlugin()); + const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/removeCode/vars.ts'), 'utf-8'), parserOptions); + traverse(ast, removeTopLevelCodePlugin(['getConfig'])); const content = generate(ast).code; expect(content.replace(/\n/g, '').replace(/\s+/g, ' ')).toBe(`import c from 'c';import d from 'd';const [x] = c;const { k} = d;export function getConfig() { return { x, k };}`); }); + + it('keep export default', () => { + const ast = parse(fs.readFileSync(path.join(__dirname, './fixtures/removeCode/export-default.ts'), 'utf-8'), parserOptions); + traverse(ast, removeTopLevelCodePlugin(['default'])); + const content = generate(ast).code; + expect(content.replace(/\n/g, '').replace(/\s+/g, ' ')).toBe(`const a = 1;export default a;`); + }); }) \ No newline at end of file diff --git a/packages/ice/tests/scan.test.ts b/packages/ice/tests/scan.test.ts index ad5626648..f721013e5 100644 --- a/packages/ice/tests/scan.test.ts +++ b/packages/ice/tests/scan.test.ts @@ -6,21 +6,34 @@ import { scanImports } from '../src/service/analyze'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); describe('scan import', () => { - const alias = { '@': path.join(__dirname, './fixtures/preAnalyze') }; - const rootDir = path.join(__dirname, './fixtures/preAnalyze'); + const alias = { '@': path.join(__dirname, './fixtures/scan') }; + const rootDir = path.join(__dirname, './fixtures/scan'); it('basic scan', async () => { - const deps = await scanImports([path.join(__dirname, './fixtures/preAnalyze/app.ts')], { alias, rootDir }); - expect(deps).toStrictEqual({ ice: 'ice', react: 'react' }); + const deps = await scanImports([path.join(__dirname, './fixtures/scan/app.ts')], { alias, rootDir }); + expect(deps['@ice/runtime'].name).toEqual('@ice/runtime'); + expect(/(@ice\/)?runtime\/package\.json/.test(deps['@ice/runtime'].pkgPath!)).toBeTruthy(); + expect(deps['@ice/runtime/client'].name).toEqual('@ice/runtime/client'); + expect(/(@ice\/)?runtime\/package\.json/.test(deps['@ice/runtime/client'].pkgPath!)).toBeTruthy(); + expect(deps.react.name).toEqual('react'); + expect(/react\/package\.json/.test(deps['react'].pkgPath!)).toBeTruthy(); }); it('scan with exclude', async () => { - const deps = await scanImports([path.join(__dirname, './fixtures/preAnalyze/app.ts')], { alias, rootDir, exclude: ['ice'] }); - expect(deps).toStrictEqual({ react: 'react' }); + const deps = await scanImports([path.join(__dirname, './fixtures/scan/app.ts')], { alias, rootDir, exclude: ['@ice/runtime'] }); + expect(deps.react.name).toEqual('react'); + expect(/react\/package\.json/.test(deps['react'].pkgPath!)).toBeTruthy(); + expect(deps['@ice/runtime']).toBeUndefined(); }); it('scan with depImports', async () => { - const deps = await scanImports([path.join(__dirname, './fixtures/preAnalyze/app.ts')], { alias, rootDir, depImports: { ice: 'ice', react: 'react' } }); - expect(deps).toStrictEqual({ ice: 'ice', react: 'react' }); + const deps = await scanImports( + [path.join(__dirname, './fixtures/scan/app.ts')], + { alias, rootDir, depImports: { '@ice/runtime': { name: '@ice/runtime' }, react: { name: 'react' } } } + ); + expect(deps['@ice/runtime'].name).toEqual('@ice/runtime'); + expect(deps['@ice/runtime'].pkgPath).toBeUndefined(); + expect(deps.react.name).toEqual('react'); + expect(deps.react.pkgPath).toBeUndefined(); }); -}); \ No newline at end of file +}); diff --git a/packages/ice/tests/transformImport.test.ts b/packages/ice/tests/transformImport.test.ts new file mode 100644 index 000000000..553cfcbeb --- /dev/null +++ b/packages/ice/tests/transformImport.test.ts @@ -0,0 +1,42 @@ +import { afterAll, expect, it } from 'vitest'; +import * as path from 'path'; +import fse from 'fs-extra'; +import { fileURLToPath } from 'url'; +import preBundleCJSDeps from '../src/service/preBundleCJSDeps'; +import { scanImports } from '../src/service/analyze'; +import esbuild from 'esbuild'; +import transformImport from '../src/esbuild/transformImport'; +import aliasPlugin from '../src/esbuild/alias'; +import { createUnplugin } from 'unplugin'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const alias = { '@': path.join(__dirname, './fixtures/scan') }; +const rootDir = path.join(__dirname, './fixtures/scan'); +const cacheDir = path.join(rootDir, '.ice'); +const appEntry = path.join(__dirname, './fixtures/scan/app.ts'); +const outdir = path.join(rootDir, 'build'); + +it('transform module import', async () => { + const deps = await scanImports([appEntry], { alias, rootDir }); + const { metadata } = await preBundleCJSDeps({ + depsInfo: deps, + cacheDir, + taskConfig: { mode: 'production' } + }); + const transformImportPlugin = createUnplugin(() => transformImport(metadata)).esbuild; + await esbuild.build({ + entryPoints: [appEntry], + outdir, + plugins: [ + aliasPlugin({ alias, format: 'esm', externalDependencies: false }), + transformImportPlugin(), + ], + }); + const buildContent = await fse.readFile(path.join(outdir, 'app.js')); + expect(buildContent.includes(path.join(rootDir, '.ice/deps/@ice_runtime_client.js'))).toBeTruthy(); + expect(buildContent.includes(path.join(rootDir, '.ice/deps/@ice_runtime.js'))).toBeTruthy(); +}); + +afterAll(async () => { + await fse.remove(cacheDir); +}); diff --git a/packages/jsx-runtime/.eslintignore b/packages/jsx-runtime/.eslintignore deleted file mode 100644 index 9071604e9..000000000 --- a/packages/jsx-runtime/.eslintignore +++ /dev/null @@ -1,12 +0,0 @@ -# 忽略目录 -.docusaurus/ -node_modules/ -**/*-min.js -**/*.min.js -coverage/ -cjs/ -esm/ -es2017/ -dist/ -build/ -tmp/ diff --git a/packages/jsx-runtime/.eslintrc.cjs b/packages/jsx-runtime/.eslintrc.cjs deleted file mode 100644 index cbcecd856..000000000 --- a/packages/jsx-runtime/.eslintrc.cjs +++ /dev/null @@ -1,7 +0,0 @@ -const { getESLintConfig } = require('@iceworks/spec'); - -module.exports = getESLintConfig('react-ts', { - env: { - jest: true - }, -}); diff --git a/packages/jsx-runtime/.stylelintignore b/packages/jsx-runtime/.stylelintignore deleted file mode 100644 index 273d17192..000000000 --- a/packages/jsx-runtime/.stylelintignore +++ /dev/null @@ -1,11 +0,0 @@ -.docusaurus/ -node_modules/ -**/*-min.css -**/*.min.css -coverage/ -cjs/ -esm/ -es2017/ -dist/ -build/ -tmp/ diff --git a/packages/jsx-runtime/.stylelintrc.cjs b/packages/jsx-runtime/.stylelintrc.cjs deleted file mode 100644 index d9ecade50..000000000 --- a/packages/jsx-runtime/.stylelintrc.cjs +++ /dev/null @@ -1,3 +0,0 @@ -const { getStylelintConfig } = require('@iceworks/spec'); - -module.exports = getStylelintConfig('react'); diff --git a/packages/jsx-runtime/package.json b/packages/jsx-runtime/package.json index 51ee6f674..031386fee 100644 --- a/packages/jsx-runtime/package.json +++ b/packages/jsx-runtime/package.json @@ -27,11 +27,7 @@ "scripts": { "watch": "ice-pkg start", "build": "ice-pkg build", - "prepublishOnly": "npm run build", - "eslint": "eslint --cache --ext .js,.jsx,.ts,.tsx ./", - "eslint:fix": "npm run eslint -- --fix", - "stylelint": "stylelint \"**/*.{css,scss,less}\"", - "lint": "npm run eslint && npm run stylelint" + "prepublishOnly": "npm run build" }, "keywords": [ "ice", @@ -42,15 +38,11 @@ "style-unit": "^3.0.4" }, "devDependencies": { - "@ice/pkg": "^1.0.0-rc.0", - "@ice/pkg-plugin-docusaurus": "^1.0.0-rc.0", - "@iceworks/spec": "^1.0.0", - "eslint": "^7.0.0", + "@ice/pkg": "^1.0.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "react": "^18.0.0", - "react-dom": "^18.0.0", - "stylelint": "^13.7.2" + "react-dom": "^18.0.0" }, "peerDependencies": { "react": "^16 || ^17 || ^18" diff --git a/packages/plugin-antd/CHANGELOG.md b/packages/plugin-antd/CHANGELOG.md new file mode 100644 index 000000000..3f9aba280 --- /dev/null +++ b/packages/plugin-antd/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +- [feat] `plugin-antd` to support `themeConfig` \ No newline at end of file diff --git a/packages/plugin-antd/README.md b/packages/plugin-antd/README.md new file mode 100644 index 000000000..e90571362 --- /dev/null +++ b/packages/plugin-antd/README.md @@ -0,0 +1,20 @@ +# `@ice/plugin-antd` + +ICE plugin for use `antd`. + +## Usage + +```js +import { defineConfig } from '@ice/app'; +import antd from '@ice/plugin-antd'; + +export default defineConfig({ + plugins: [antd({ + dark: true, + compact: true, + theme: { + 'primary-color': '#fd8', + } + })], +}); +``` \ No newline at end of file diff --git a/packages/plugin-antd/package.json b/packages/plugin-antd/package.json new file mode 100644 index 000000000..0c3638952 --- /dev/null +++ b/packages/plugin-antd/package.json @@ -0,0 +1,31 @@ +{ + "name": "@ice/plugin-antd", + "version": "1.0.0", + "description": "ice plugin for use antd", + "type": "module", + "scripts": { + "watch": "tsc -w", + "build": "tsc" + }, + "main": "./esm/index.js", + "types": "./esm/index.d.ts", + "author": "", + "license": "MIT", + "repository": { + "type": "http", + "url": "https://github.com/ice-lab/ice-next/tree/master/packages/plugin-antd" + }, + "devDependencies": { + "@ice/types": "^1.0.0" + }, + "dependencies": { + "@ice/style-import": "^1.0.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "esm", + "!esm/**/*.map" + ] +} diff --git a/packages/plugin-antd/src/index.ts b/packages/plugin-antd/src/index.ts new file mode 100644 index 000000000..9b465dc0d --- /dev/null +++ b/packages/plugin-antd/src/index.ts @@ -0,0 +1,75 @@ +import { createRequire } from 'module'; +import type { Plugin } from '@ice/types'; +import styleImportPlugin from '@ice/style-import'; + +interface PluginOptions { + theme?: Record<string, string>; + dark?: Boolean; + compact?: Boolean; + importStyle?: Boolean; +} + +const require = createRequire(import.meta.url); + +const plugin: Plugin<PluginOptions> = ({ theme, dark, compact, importStyle }) => ({ + name: '@ice/plugin-antd', + setup: ({ onGetConfig }) => { + if (importStyle) { + onGetConfig((config) => { + config.transformPlugins = [...(config.transformPlugins || []), styleImportPlugin({ + libraryName: 'antd', + style: (name) => `antd/es/${name.toLocaleLowerCase()}/style`, + })]; + }); + } + if (theme || dark || compact) { + onGetConfig((config) => { + // Modify webpack config of less rule for antd theme. + config.configureWebpack ??= []; + config.configureWebpack.push((webpackConfig) => { + const { rules } = webpackConfig.module; + let lessLoader = null; + rules.some((rule) => { + if (typeof rule === 'object' && + rule.test instanceof RegExp && + rule?.test?.source?.match(/less/)) { + lessLoader = Array.isArray(rule?.use) && + rule.use.find((use) => typeof use === 'object' && use.loader.includes('less-loader')); + return true; + } + return false; + }); + if (lessLoader) { + let themeConfig = theme || {}; + if (dark || compact) { + // Try to get theme config for antd. + const { getThemeVariables } = require('antd/dist/theme'); + themeConfig = { + ...(getThemeVariables({ + dark, + compact, + })), + ...themeConfig, + }; + } + + const loaderOptions = lessLoader.options || {}; + lessLoader.options = { + ...loaderOptions, + lessOptions: { + ...(loaderOptions?.lessOptions || {}), + modifyVars: { + ...(loaderOptions?.lessOptions?.modifyVars || {}), + ...themeConfig, + }, + }, + }; + } + return webpackConfig; + }); + }); + } + }, +}); + +export default plugin; \ No newline at end of file diff --git a/packages/plugin-antd/tsconfig.json b/packages/plugin-antd/tsconfig.json new file mode 100644 index 000000000..5647eb03b --- /dev/null +++ b/packages/plugin-antd/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "src", + "outDir": "esm" + }, + "include": ["src"] +} \ No newline at end of file diff --git a/packages/plugin-auth/package.json b/packages/plugin-auth/package.json index 9c6a8deb0..cc7752280 100644 --- a/packages/plugin-auth/package.json +++ b/packages/plugin-auth/package.json @@ -37,10 +37,8 @@ "esm", "!esm/**/*.map" ], - "dependencies": { - "@ice/types": "^1.0.0" - }, "devDependencies": { + "@ice/types": "^1.0.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "regenerator-runtime": "^0.13.9" diff --git a/packages/plugin-auth/src/index.ts b/packages/plugin-auth/src/index.ts index e52fc6e0d..03a6721ee 100644 --- a/packages/plugin-auth/src/index.ts +++ b/packages/plugin-auth/src/index.ts @@ -2,16 +2,16 @@ import path from 'path'; import { fileURLToPath } from 'url'; import type { Plugin } from '@ice/types'; -const plugin: Plugin = ({ generator }) => { - // 注册 API:import { useAuth, withAuth } from 'ice'; - generator.addExport({ - specifier: ['withAuth', 'useAuth'], - source: '@ice/plugin-auth/runtime/Auth', - }); -}; - -export default () => ({ +const plugin: Plugin = () => ({ name: '@ice/plugin-auth', - setup: plugin, + setup: ({ generator }) => { + // Register API: `import { useAuth, withAuth } from 'ice';` + generator.addExport({ + specifier: ['withAuth', 'useAuth'], + source: '@ice/plugin-auth/runtime/Auth', + }); + }, runtime: path.join(path.dirname(fileURLToPath(import.meta.url)), 'runtime', 'index.js'), }); + +export default plugin; diff --git a/packages/plugin-fusion/CHANGELOG.md b/packages/plugin-fusion/CHANGELOG.md new file mode 100644 index 000000000..38f3cb049 --- /dev/null +++ b/packages/plugin-fusion/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +- [feat] support use fusion component in ICE framework. \ No newline at end of file diff --git a/packages/plugin-fusion/README.md b/packages/plugin-fusion/README.md new file mode 100644 index 000000000..ff5acb377 --- /dev/null +++ b/packages/plugin-fusion/README.md @@ -0,0 +1,27 @@ +# `plugin-fusion` + +plugin for use fusion component in framework `ice`. + +## Usage + +```js +import { defineConfig } from '@ice/app'; +import fusion from '@ice/plugin-fusion'; + +export default defineConfig({ + plugins: [fusion({ + importStyle: true, + themePackage: '@alifd/theme-design-pro', + theme: { + 'primary-color': '#fff', + }, + })], +}); +``` + +## Options + +- importStyle: 开启后 Fusion 组件样式将自动引入 +- themePackage: Fusion 组件主题包配置,如果设置为数组则启动多主题能力 +- theme: 主题配置,通过设置 sass 变量对现有主题进行覆盖 + diff --git a/packages/plugin-fusion/package.json b/packages/plugin-fusion/package.json new file mode 100644 index 000000000..9bc7a1e18 --- /dev/null +++ b/packages/plugin-fusion/package.json @@ -0,0 +1,30 @@ +{ + "name": "@ice/plugin-fusion", + "version": "1.0.0", + "description": "plugin for ICE while use fusion component", + "license": "MIT", + "type": "module", + "main": "./esm/index.js", + "types": "./esm/index.d.ts", + "files": [ + "esm", + "!esm/**/*.map" + ], + "dependencies": { + "@ice/style-import": "^1.0.0" + }, + "devDependencies": { + "@ice/types": "^1.0.0" + }, + "repository": { + "type": "http", + "url": "https://github.com/ice-lab/ice-next/tree/master/packages/plugin-fusion" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "watch": "tsc -w", + "build": "tsc" + } +} diff --git a/packages/plugin-fusion/src/index.ts b/packages/plugin-fusion/src/index.ts new file mode 100644 index 000000000..c478f5a7a --- /dev/null +++ b/packages/plugin-fusion/src/index.ts @@ -0,0 +1,102 @@ +import { createRequire } from 'module'; +import type { Plugin } from '@ice/types'; +import styleImportPlugin from '@ice/style-import'; + +interface PluginOptions { + theme?: Record<string, string>; + themePackage?: string; + importStyle?: Boolean | string; +} + +const require = createRequire(import.meta.url); + +function getVariablesPath({ + packageName, + filename = 'variables.scss', + silent = false, +}) { + let filePath = ''; + const variables = `${packageName}/${filename}`; + try { + filePath = require.resolve(variables); + } catch (err) { + if (!silent) { + console.log('[ERROR]', `fail to resolve ${variables}`); + } + } + return filePath; +} + +const plugin: Plugin<PluginOptions> = (options = {}) => ({ + name: '@ice/plugin-fusion', + setup: ({ onGetConfig }) => { + const { theme, themePackage, importStyle } = options; + if (importStyle) { + onGetConfig((config) => { + config.transformPlugins = [...(config.transformPlugins || []), styleImportPlugin({ + libraryName: '@alifd/next', + style: (name) => `@alifd/next/es/${name.toLocaleLowerCase()}/${importStyle === 'sass' ? 'style' : 'style2'}`, + })]; + }); + } + if (theme || themePackage) { + onGetConfig((config) => { + // Modify webpack config of scss rule for fusion theme. + config.configureWebpack ??= []; + config.configureWebpack.push((webpackConfig) => { + const { rules } = webpackConfig.module; + let sassLoader = null; + rules.some((rule) => { + if (typeof rule === 'object' && + rule.test instanceof RegExp && + rule?.test?.source?.match(/scss/)) { + sassLoader = Array.isArray(rule?.use) && + rule.use.find((use) => typeof use === 'object' && use.loader.includes('sass-loader')); + return true; + } + return false; + }); + if (sassLoader) { + let additionalContent = ''; + if (themePackage) { + const themeFile = getVariablesPath({ + packageName: themePackage, + }); + if (themeFile) { + additionalContent += `@import '${themePackage}/variables.scss'`; + } + // Try to get icon.scss if exists. + const iconFile = getVariablesPath({ + packageName: themePackage, + filename: 'icons.scss', + silent: true, + }); + if (iconFile) { + additionalContent += `@import '${themePackage}/icons.scss'`; + } + } + let themeConfig = []; + Object.keys(theme || {}).forEach((key) => { + themeConfig.push(`$${key}: ${theme[key]};`); + }); + additionalContent += themeConfig.join('\n'); + + const loaderOptions = sassLoader.options || {}; + sassLoader.options = { + ...loaderOptions, + additionalData: (content, loaderContext) => { + const originalContent = typeof loaderOptions.additionalData === 'function' + ? loaderOptions.additionalData(content, loaderContext) + : `${loaderOptions.additionalData || ''}${content}`; + return `${additionalContent}\n${originalContent}`; + }, + }; + } + return webpackConfig; + }); + }); + } + }, +}); + +export default plugin; diff --git a/packages/plugin-fusion/tsconfig.json b/packages/plugin-fusion/tsconfig.json new file mode 100644 index 000000000..5647eb03b --- /dev/null +++ b/packages/plugin-fusion/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "src", + "outDir": "esm" + }, + "include": ["src"] +} \ No newline at end of file diff --git a/packages/plugin-moment-locales/CHANGELOG.md b/packages/plugin-moment-locales/CHANGELOG.md new file mode 100644 index 000000000..f74d4b089 --- /dev/null +++ b/packages/plugin-moment-locales/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +- [feat] support config locales of moment. \ No newline at end of file diff --git a/packages/plugin-moment-locales/README.md b/packages/plugin-moment-locales/README.md new file mode 100644 index 000000000..265dfcef7 --- /dev/null +++ b/packages/plugin-moment-locales/README.md @@ -0,0 +1,16 @@ +# @ice/plugin-moment-locales + +plugin for load moment locales and reduce size of moment + +## Usage + +```js +import { defineConfig } from '@ice/app'; +import moment from '@ice/plugin-moment-locale'; + +export default defineConfig({ + plugins: [moment({ + locales: ['zh-CN'] + })], +}); +``` \ No newline at end of file diff --git a/packages/plugin-moment-locales/package.json b/packages/plugin-moment-locales/package.json new file mode 100644 index 000000000..6e291c240 --- /dev/null +++ b/packages/plugin-moment-locales/package.json @@ -0,0 +1,28 @@ +{ + "name": "@ice/plugin-moment-locales", + "version": "1.0.0", + "description": "ICE plugins for reduce moment locale size", + "type": "module", + "scripts": { + "watch": "tsc -w", + "build": "tsc" + }, + "main": "./esm/index.js", + "types": "./esm/index.d.ts", + "author": "", + "license": "MIT", + "repository": { + "type": "http", + "url": "https://github.com/ice-lab/ice-next/tree/master/packages/plugin-moment-locales" + }, + "devDependencies": { + "@ice/types": "^1.0.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "esm", + "!esm/**/*.map" + ] +} \ No newline at end of file diff --git a/packages/plugin-moment-locales/src/index.ts b/packages/plugin-moment-locales/src/index.ts new file mode 100644 index 000000000..43e5273fe --- /dev/null +++ b/packages/plugin-moment-locales/src/index.ts @@ -0,0 +1,24 @@ +import type { Plugin } from '@ice/types'; + +interface PluginOptions { + locales: string | string[]; +} + +const plugin: Plugin<PluginOptions> = (options) => ({ + name: '@ice/plugin-moment-locales', + setup: ({ onGetConfig, context }) => { + const { locales } = options || {}; + if (locales) { + onGetConfig((config) => { + config.plugins ??= []; + const localeArray = typeof locales === 'string' ? [locales] : locales; + config.plugins.push(new context.webpack.ContextReplacementPlugin( + /moment[/\\]locale$/, + new RegExp(localeArray.join('|')), + )); + }); + } + }, +}); + +export default plugin; diff --git a/packages/plugin-moment-locales/tsconfig.json b/packages/plugin-moment-locales/tsconfig.json new file mode 100644 index 000000000..972f3542f --- /dev/null +++ b/packages/plugin-moment-locales/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "src", + "outDir": "esm", + "jsx": "react" + }, + "include": ["src"] +} \ No newline at end of file diff --git a/packages/plugin-pha/package.json b/packages/plugin-pha/package.json index bf90dda28..e20d0e313 100644 --- a/packages/plugin-pha/package.json +++ b/packages/plugin-pha/package.json @@ -16,10 +16,6 @@ "build": "tsc" }, "dependencies": { - "@babel/types": "^7.18.6", - "@babel/generator": "^7.18.7", - "@babel/parser": "^7.18.6", - "@babel/traverse": "^7.18.6", "chalk": "^4.0.0", "consola": "^2.15.3", "humps": "^2.0.1", @@ -27,9 +23,6 @@ }, "devDependencies": { "@ice/types": "^1.0.0", - "@types/babel__traverse": "^7.17.1", - "@types/babel__generator": "^7.6.4", - "@types/lodash.clonedeep": "^4.5.7", "esbuild": "^0.14.23", "webpack": "^5.73.0", "webpack-dev-server": "^4.9.2" diff --git a/packages/plugin-pha/src/constants.ts b/packages/plugin-pha/src/constants.ts index 4e4fff93c..a7ca0dfc1 100644 --- a/packages/plugin-pha/src/constants.ts +++ b/packages/plugin-pha/src/constants.ts @@ -75,5 +75,3 @@ export const validPageConfigKeys = [ 'queryParamsPassKeys', 'queryParamsPassIgnoreKeys', ]; - -export const templateFile = '.ice/pha-manifest.ts'; \ No newline at end of file diff --git a/packages/plugin-pha/src/generateManifest.ts b/packages/plugin-pha/src/generateManifest.ts index cdb717bf9..e643a9e74 100644 --- a/packages/plugin-pha/src/generateManifest.ts +++ b/packages/plugin-pha/src/generateManifest.ts @@ -1,8 +1,7 @@ import * as path from 'path'; import * as fs from 'fs'; +import type { GetAppConfig, GetRoutesConfig, ServerCompiler } from '@ice/types/esm/plugin.js'; import { parseManifest, rewriteAppWorker, getAppWorkerUrl, getMultipleManifest, type ParseOptions } from './manifestHelpers.js'; -import { templateFile } from './constants.js'; -import type { Manifest } from './types.js'; import type { Compiler } from './index.js'; export interface Options { @@ -10,7 +9,9 @@ export interface Options { outputDir: string; parseOptions: Partial<ParseOptions>; compiler: Compiler; - compileTask?: () => Promise<{ serverEntry: string}>; + getAppConfig: GetAppConfig; + getRoutesConfig: GetRoutesConfig; + compileTask?: () => ReturnType<ServerCompiler>; } export async function getAppWorkerContent( @@ -30,31 +31,16 @@ export async function getAppWorkerContent( return fs.readFileSync(appWorkerFile, 'utf-8'); } -// Compile task before parse pha manifest. -export async function compileEntires( - compiler: Compiler, - { rootDir, outputDir }: { rootDir: string; outputDir: string }) { - return await Promise.all([ - compiler({ - entry: path.join(rootDir, templateFile), - outfile: path.join(outputDir, 'pha-manifest.mjs'), - }), - compiler({ - entry: path.join(rootDir, '.ice/routes-config.ts'), - outfile: path.join(outputDir, 'routes-config.mjs'), - removeCode: true, - }), - ]); -} - export default async function generateManifest({ rootDir, outputDir, parseOptions, + getAppConfig, + getRoutesConfig, compiler, }: Options) { - const [manifestEntry, routesConfigEntry] = await compileEntires(compiler, { rootDir, outputDir }); - let manifest: Manifest = (await import(manifestEntry)).default; + const [appConfig, routesConfig] = await Promise.all([getAppConfig(['phaManifest']), getRoutesConfig()]); + let manifest = appConfig.phaManifest; const appWorkerPath = getAppWorkerUrl(manifest, path.join(rootDir, 'src')); if (appWorkerPath) { manifest = rewriteAppWorker(manifest); @@ -66,7 +52,7 @@ export default async function generateManifest({ } const phaManifest = await parseManifest(manifest, { ...parseOptions, - configEntry: routesConfigEntry, + routesConfig, } as ParseOptions); if (phaManifest?.tab_bar) { fs.writeFileSync(path.join(outputDir, 'manifest.json'), JSON.stringify(phaManifest), 'utf-8'); diff --git a/packages/plugin-pha/src/index.ts b/packages/plugin-pha/src/index.ts index 064d698d2..49834d2aa 100644 --- a/packages/plugin-pha/src/index.ts +++ b/packages/plugin-pha/src/index.ts @@ -1,14 +1,10 @@ import * as path from 'path'; -import { fileURLToPath } from 'url'; import consola from 'consola'; import chalk from 'chalk'; import type { Plugin } from '@ice/types'; +import type { GetAppConfig, GetRoutesConfig } from '@ice/types/esm/plugin.js'; import generateManifest from './generateManifest.js'; import createPHAMiddleware from './phaMiddleware.js'; -import { templateFile } from './constants.js'; -import removeCodePlugin from './removeCodePlugin.js'; - -import type { Manifest } from './types.js'; export type Compiler = (options: { entry: string; @@ -18,8 +14,6 @@ export type Compiler = (options: { removeCode?: boolean; }) => Promise<string>; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - interface PluginOptions { template: boolean; } @@ -28,116 +22,120 @@ function getDevPath(url: string): string { return url.startsWith('http') ? `${new URL(url).origin}/` : url; } -const plugin: Plugin<PluginOptions> = ({ onGetConfig, onHook, context, generator, serverCompileTask }, options) => { - const { template } = options || {}; - const { command, rootDir } = context; - - // Get variable blows from task config. - let compiler: Compiler; - let publicPath: string; - let outputDir: string; - let urlPrefix: string; - - generator.addRenderFile(path.join(__dirname, '../template/manifest.ts'), path.join(rootDir, templateFile)); - - // Get server compiler by hooks - onHook(`before.${command as 'start' | 'build'}.run`, async ({ serverCompiler, taskConfigs, urls }) => { - const taskConfig = taskConfigs.find(({ name }) => name === 'web').config; - outputDir = path.isAbsolute(taskConfig.outputDir) ? taskConfig.outputDir : path.join(rootDir, taskConfig.outputDir); - - // Need absolute path for pha dev. - publicPath = command === 'start' ? getDevPath(urls.lanUrlForTerminal) : (taskConfig.publicPath || '/'); - - // process.env.DEPLOY_PATH is defined by cloud environment such as DEF plugin. - urlPrefix = command === 'start' ? urls.lanUrlForTerminal : process.env.DEPLOY_PATH; - - compiler = async (options) => { - const { entry, outfile, removeCode, timestamp = true, minify = false } = options; - await serverCompiler({ - entryPoints: [entry], - format: 'esm', - outfile, - minify, - inject: [], - plugins: removeCode ? [removeCodePlugin()] : [], - }); - return `${outfile}${timestamp ? `?version=${new Date().getTime()}` : ''}`; - }; - }); - - onHook('after.build.compile', async ({ serverEntry }) => { - await generateManifest({ - rootDir, - outputDir, - compiler, - parseOptions: { - publicPath, - urlPrefix, - serverEntry, - template, - }, - }); - }); - - onHook('after.start.compile', async ({ urls }) => { - // Log out pha dev urls. - const lanUrl = urls.lanUrlForTerminal; - const phaManifestPath = path.join(rootDir, templateFile); - const manifestOutfile = path.join(outputDir, 'pha-manifest.mjs'); - const phaManifest: Manifest = (await import( - await compiler({ entry: phaManifestPath, outfile: manifestOutfile }) - )).default; - const phaDevUrls = []; - if (phaManifest?.tabBar) { - phaDevUrls.push(`${lanUrl}manifest.json`); - } else if (phaManifest?.routes?.length > 0) { - phaManifest.routes.forEach((route) => { - if (typeof route === 'string') { - phaDevUrls.push(`${lanUrl}${route}-manifest.json`); - } else if (typeof route?.frames![0] === 'string') { - phaDevUrls.push(`${lanUrl}${route.frames[0]}-manifest.json`); - } - }); - } - let logoutMessage = '\n'; - logoutMessage += chalk.green(' Serve PHA Manifest at:\n'); - phaDevUrls.forEach((url) => { - logoutMessage += `\n ${chalk.underline.white(url)}`; +const plugin: Plugin<PluginOptions> = (options) => ({ + name: '@ice/plugin-pha', + setup: ({ onGetConfig, onHook, context, serverCompileTask }) => { + const { template } = options || {}; + const { command, rootDir } = context; + + // Get variable blows from task config. + let compiler: Compiler; + let publicPath: string; + let outputDir: string; + let urlPrefix: string; + let getAppConfig: GetAppConfig; + let getRoutesConfig: GetRoutesConfig; + + // Get server compiler by hooks + onHook(`before.${command as 'start' | 'build'}.run`, async ({ serverCompiler, taskConfigs, urls, ...restAPI }) => { + const taskConfig = taskConfigs.find(({ name }) => name === 'web').config; + outputDir = path.isAbsolute(taskConfig.outputDir) + ? taskConfig.outputDir : path.join(rootDir, taskConfig.outputDir); + + getAppConfig = restAPI.getAppConfig; + getRoutesConfig = restAPI.getRoutesConfig; + + // Need absolute path for pha dev. + publicPath = command === 'start' ? getDevPath(urls.lanUrlForTerminal) : (taskConfig.publicPath || '/'); + + // process.env.DEPLOY_PATH is defined by cloud environment such as DEF plugin. + urlPrefix = command === 'start' ? urls.lanUrlForTerminal : process.env.DEPLOY_PATH; + + compiler = async (options) => { + const { entry, outfile, minify = false } = options; + await serverCompiler({ + entryPoints: [entry], + format: 'esm', + outfile, + minify, + inject: [], + }); + return `${outfile}`; + }; }); - if (phaDevUrls.length > 0) { - consola.log(`${logoutMessage}\n`); - } - }); - onGetConfig('web', (config) => { - const customMiddlewares = config.middlewares; - config.middlewares = (middlewares, devServer) => { - const currentMiddlewares = customMiddlewares ? customMiddlewares(middlewares, devServer) : middlewares; - const insertIndex = currentMiddlewares.findIndex(({ name }) => name === 'server-compile'); - const phaMiddleware = createPHAMiddleware({ - compiler, + onHook('after.build.compile', async ({ serverEntry }) => { + await generateManifest({ rootDir, outputDir, - compileTask: () => serverCompileTask.get(), + compiler, + getAppConfig, + getRoutesConfig, parseOptions: { publicPath, urlPrefix, + serverEntry, template, }, }); + }); - // Add pha middleware after server-compile. - middlewares.splice(insertIndex + 1, 0, { - name: 'pha-manifest', - middleware: phaMiddleware, + onHook('after.start.compile', async ({ urls }) => { + // Log out pha dev urls. + const lanUrl = urls.lanUrlForTerminal; + const appConfig = await getAppConfig(['phaManifest']); + const { phaManifest } = appConfig || {}; + const phaDevUrls = []; + if (phaManifest?.tabBar) { + phaDevUrls.push(`${lanUrl}manifest.json`); + } else if (phaManifest?.routes?.length > 0) { + phaManifest.routes.forEach((route) => { + if (typeof route === 'string') { + phaDevUrls.push(`${lanUrl}${route}-manifest.json`); + } else if (typeof route?.frames![0] === 'string') { + phaDevUrls.push(`${lanUrl}${route.frames[0]}-manifest.json`); + } + }); + } + let logoutMessage = '\n'; + logoutMessage += chalk.green(' Serve PHA Manifest at:\n'); + phaDevUrls.forEach((url) => { + logoutMessage += `\n ${chalk.underline.white(url)}`; }); - return currentMiddlewares; - }; - return config; - }); -}; + if (phaDevUrls.length > 0) { + consola.log(`${logoutMessage}\n`); + } + }); -export default (options: PluginOptions) => ({ - name: '@ice/plugin-pha', - setup: (api) => plugin(api, options), -}); \ No newline at end of file + onGetConfig('web', (config) => { + const customMiddlewares = config.middlewares; + config.middlewares = (middlewares, devServer) => { + const currentMiddlewares = customMiddlewares ? customMiddlewares(middlewares, devServer) : middlewares; + const insertIndex = currentMiddlewares.findIndex(({ name }) => name === 'server-compile'); + const phaMiddleware = createPHAMiddleware({ + compiler, + rootDir, + outputDir, + getAppConfig, + getRoutesConfig, + compileTask: () => serverCompileTask.get(), + parseOptions: { + publicPath, + urlPrefix, + template, + }, + }); + + // Add pha middleware after server-compile. + middlewares.splice(insertIndex + 1, 0, { + name: 'pha-manifest', + middleware: phaMiddleware, + }); + return currentMiddlewares; + }; + return config; + }); + }, +}); + +export default plugin; diff --git a/packages/plugin-pha/src/manifestHelpers.ts b/packages/plugin-pha/src/manifestHelpers.ts index 445639b9f..b0877a561 100644 --- a/packages/plugin-pha/src/manifestHelpers.ts +++ b/packages/plugin-pha/src/manifestHelpers.ts @@ -17,7 +17,7 @@ interface TransformOptions { export interface ParseOptions { urlPrefix: string; publicPath: string; - configEntry: string; + routesConfig?: Record<string, any>; serverEntry: string; template?: boolean; urlSuffix?: string; @@ -94,8 +94,7 @@ function getPageUrl(routeId: string, options: ParseOptions) { return `${urlPrefix}${splitCharacter}${routeId}${urlSuffix}`; } -async function getPageConfig(routeId: string, configEntry: string): Promise<MixedPage> { - const routesConfig = (await import(configEntry)).default; +async function getPageConfig(routeId: string, routesConfig: Record<string, any>): Promise<MixedPage> { const routeConfig = routesConfig![`/${routeId}`]?.() as MixedPage || {}; const filteredConfig = {}; Object.keys(routeConfig).forEach((key) => { @@ -122,11 +121,11 @@ async function renderPageDocument(routeId: string, serverEntry: string): Promise } async function getPageManifest(page: string | Page, options: ParseOptions): Promise<MixedPage> { - const { template, serverEntry, configEntry } = options; + const { template, serverEntry, routesConfig } = options; // Page will be type string when it is a source frame. if (typeof page === 'string') { // Get html content by render document. - const pageConfig = await getPageConfig(page, configEntry); + const pageConfig = await getPageConfig(page, routesConfig); const { queryParams = '', ...rest } = pageConfig; const pageManifest = { key: page, diff --git a/packages/plugin-pha/src/phaMiddleware.ts b/packages/plugin-pha/src/phaMiddleware.ts index 9ac1b67a3..7d5f29989 100644 --- a/packages/plugin-pha/src/phaMiddleware.ts +++ b/packages/plugin-pha/src/phaMiddleware.ts @@ -1,8 +1,9 @@ import * as path from 'path'; import type { ServerResponse } from 'http'; import type { ExpressRequestHandler } from 'webpack-dev-server'; +import consola from 'consola'; import { parseManifest, rewriteAppWorker, getAppWorkerUrl, getMultipleManifest, type ParseOptions } from './manifestHelpers.js'; -import { getAppWorkerContent, compileEntires, type Options } from './generateManifest.js'; +import { getAppWorkerContent, type Options } from './generateManifest.js'; import type { Manifest } from './types.js'; function sendResponse(res: ServerResponse, content: string, mime: string): void { @@ -16,6 +17,8 @@ const createPHAMiddleware = ({ outputDir, compileTask, parseOptions, + getAppConfig, + getRoutesConfig, compiler, }: Options): ExpressRequestHandler => { const phaMiddleware: ExpressRequestHandler = async (req, res, next) => { @@ -26,9 +29,13 @@ const createPHAMiddleware = ({ const requestAppWorker = req.url === '/app-worker.js'; if (requestManifest || requestAppWorker) { // Get serverEntry from middleware of server-compile. - const { serverEntry } = await compileTask(); - const [manifestEntry, routesConfigEntry] = await compileEntires(compiler, { rootDir, outputDir }); - let manifest: Manifest = (await import(manifestEntry)).default; + const { error, serverEntry } = await compileTask(); + if (error) { + consola.error('Server compile error in PHA middleware.'); + return; + } + const [appConfig, routesConfig] = await Promise.all([getAppConfig(['phaManifest']), getRoutesConfig()]); + let manifest: Manifest = appConfig.phaManifest; const appWorkerPath = getAppWorkerUrl(manifest, path.join(rootDir, 'src')); if (appWorkerPath) { // over rewrite appWorker.url to app-worker.js @@ -47,7 +54,7 @@ const createPHAMiddleware = ({ } const phaManifest = await parseManifest(manifest, { ...parseOptions, - configEntry: routesConfigEntry, + routesConfig, serverEntry: serverEntry, } as ParseOptions); if (!phaManifest?.tab_bar) { diff --git a/packages/plugin-pha/template/manifest.ts b/packages/plugin-pha/template/manifest.ts deleted file mode 100644 index c30d37149..000000000 --- a/packages/plugin-pha/template/manifest.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @ts-ignore -import { phaManifest } from '@/app'; -export default phaManifest; \ No newline at end of file diff --git a/packages/plugin-pha/tests/manifestHelper.test.ts b/packages/plugin-pha/tests/manifestHelper.test.ts index 0861ed436..458337861 100644 --- a/packages/plugin-pha/tests/manifestHelper.test.ts +++ b/packages/plugin-pha/tests/manifestHelper.test.ts @@ -239,11 +239,11 @@ describe('transform config keys', () => { }); }); -describe('parse manifest', () => { +describe('parse manifest', async () => { const options = { publicPath: 'https://cdn-path.com/', urlPrefix: 'https://url-prefix.com/', - configEntry: path.join(__dirname, './mockConfig.mjs'), + routesConfig: (await import(path.join(__dirname, './mockConfig.mjs')))?.default, serverEntry: path.join(__dirname, './mockServer.mjs'), }; @@ -447,11 +447,11 @@ describe('parse manifest', () => { }); }); -describe('get multiple manifest', () => { +describe('get multiple manifest', async () => { const options = { publicPath: 'https://cdn-path.com/', urlPrefix: 'https://url-prefix.com/', - configEntry: path.join(__dirname, './mockConfig.mjs'), + routesConfig: (await import(path.join(__dirname, './mockConfig.mjs')))?.default, serverEntry: path.join(__dirname, './mockServer.mjs'), }; diff --git a/packages/plugin-rax-compat/src/index.ts b/packages/plugin-rax-compat/src/index.ts index 3c8611da6..bd7c6e4ac 100644 --- a/packages/plugin-rax-compat/src/index.ts +++ b/packages/plugin-rax-compat/src/index.ts @@ -31,8 +31,13 @@ const ruleSetStylesheet = { let warnOnce = false; -function getPlugin(options: CompatRaxOptions): Plugin { - return ({ onGetConfig }) => { +export interface CompatRaxOptions { + inlineStyle?: boolean; +} + +const plugin: Plugin<CompatRaxOptions> = (options = {}) => ({ + name: '@ice/plugin-rax-compat', + setup: ({ onGetConfig }) => { onGetConfig((config) => { // Reset jsc.transform.react.runtime to classic. config.swcOptions = merge(config.swcOptions || {}, { @@ -77,14 +82,7 @@ function getPlugin(options: CompatRaxOptions): Plugin { }); } }); - }; -} - -export interface CompatRaxOptions { - inlineStyle?: boolean; -} - -export default (options: CompatRaxOptions | void) => ({ - name: '@ice/plugin-rax-compat', - setup: getPlugin(options || {}), + }, }); + +export default plugin; diff --git a/packages/plugin-store/CHANGELOG.md b/packages/plugin-store/CHANGELOG.md new file mode 100644 index 000000000..5889afa76 --- /dev/null +++ b/packages/plugin-store/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## 1.0.0 + +- feat: init plugin store diff --git a/packages/plugin-store/README.md b/packages/plugin-store/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugin-store/package.json b/packages/plugin-store/package.json new file mode 100644 index 000000000..3eb801350 --- /dev/null +++ b/packages/plugin-store/package.json @@ -0,0 +1,54 @@ +{ + "name": "@ice/plugin-store", + "version": "1.0.0", + "description": "", + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./esm/index.d.ts", + "import": "./esm/index.js", + "default": "./esm/index.js" + }, + "./runtime": { + "types": "./esm/runtime.d.ts", + "import": "./esm/runtime.js", + "default": "./esm/runtime.js" + }, + "./esm/runtime": { + "types": "./esm/runtime.d.ts", + "import": "./esm/runtime.js", + "default": "./esm/runtime.js" + } + }, + "main": "./esm/index.js", + "types": "./esm/index.d.ts", + "files": [ + "esm", + "!esm/**/*.map" + ], + "dependencies": { + "@ice/store": "^2.0.1", + "fast-glob": "^3.2.11", + "micromatch": "^4.0.5" + }, + "devDependencies": { + "@ice/types": "^1.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "@types/micromatch": "^4.0.2", + "regenerator-runtime": "^0.13.9" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "repository": { + "type": "http", + "url": "https://github.com/ice-lab/ice-next/tree/master/packages/plugin-store" + }, + "scripts": { + "watch": "tsc -w", + "build": "tsc" + } +} \ No newline at end of file diff --git a/packages/plugin-store/src/_store.ts b/packages/plugin-store/src/_store.ts new file mode 100644 index 000000000..ff1ba0c93 --- /dev/null +++ b/packages/plugin-store/src/_store.ts @@ -0,0 +1,10 @@ +import type { IcestoreDispatch, IcestoreRootState } from '@ice/store'; +import { createStore } from '@ice/store'; + +const models = {}; + +const store = createStore(models); + +export default store; +export type IRootDispatch = IcestoreDispatch<typeof models>; +export type IRootState = IcestoreRootState<typeof models>; diff --git a/packages/plugin-store/src/constants.ts b/packages/plugin-store/src/constants.ts new file mode 100644 index 000000000..95fa1cbbe --- /dev/null +++ b/packages/plugin-store/src/constants.ts @@ -0,0 +1,3 @@ +export const PAGE_STORE_MODULE = '__PAGE_STORE__'; +export const PAGE_STORE_PROVIDER = '__PAGE_STORE_PROVIDER__'; +export const PAGE_STORE_INITIAL_STATES = '__PAGE_STORE_INITIAL_STATES__'; \ No newline at end of file diff --git a/packages/plugin-store/src/index.ts b/packages/plugin-store/src/index.ts new file mode 100644 index 000000000..0e0edf62b --- /dev/null +++ b/packages/plugin-store/src/index.ts @@ -0,0 +1,112 @@ +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import type { Config, Plugin } from '@ice/types'; +import micromatch from 'micromatch'; +import fg from 'fast-glob'; +import { PAGE_STORE_MODULE, PAGE_STORE_PROVIDER, PAGE_STORE_INITIAL_STATES } from './constants.js'; + +interface Options { + disableResetPageState?: boolean; +} +const storeFilePattern = '**/store.{js,ts}'; +const ignoreStoreFilePatterns = ['**/models/**', storeFilePattern]; + +const plugin: Plugin<Options> = (options) => ({ + name: '@ice/plugin-store', + setup: ({ onGetConfig, modifyUserConfig, context: { rootDir, userConfig } }) => { + const { disableResetPageState = false } = options || {}; + const srcDir = path.join(rootDir, 'src'); + const pageDir = path.join(srcDir, 'pages'); + + modifyUserConfig('routes', { + ...(userConfig.routes || {}), + ignoreFiles: [...(userConfig?.routes?.ignoreFiles || []), ...ignoreStoreFilePatterns], + }); + + onGetConfig(config => { + // Add app store provider. + const appStorePath = getAppStorePath(srcDir); + if (appStorePath) { + config.alias = { + ...config.alias || {}, + $store: appStorePath, + }; + } + config.transformPlugins = [ + ...(config.transformPlugins || []), + exportStoreProviderPlugin({ pageDir, disableResetPageState }), + ]; + return config; + }); + }, + runtime: path.join(path.dirname(fileURLToPath(import.meta.url)), 'runtime.js'), +}); + +function exportStoreProviderPlugin({ pageDir, disableResetPageState }: { pageDir: string; disableResetPageState: boolean }): Config['transformPlugins'][0] { + return { + name: 'export-store-provider', + enforce: 'post', + transformInclude: (id) => { + return id.startsWith(pageDir) && !micromatch.isMatch(id, ignoreStoreFilePatterns); + }, + transform: async (source, id) => { + const pageStorePath = getPageStorePath(id); + if (pageStorePath) { + if ( + isLayout(id) || // Current id is layout. + !isLayoutExisted(id) // If current id is route and there is no layout in the current dir. + ) { + return exportPageStore(source, disableResetPageState); + } + } + return source; + }, + }; +} + +function exportPageStore(source: string, disableResetPageState: boolean) { + const importStoreStatement = `import ${PAGE_STORE_MODULE} from './store';\n`; + const exportStoreProviderStatement = disableResetPageState ? ` +const { Provider: ${PAGE_STORE_PROVIDER} } = ${PAGE_STORE_MODULE}; +export { ${PAGE_STORE_PROVIDER} };` : ` +const { Provider: ${PAGE_STORE_PROVIDER}, getState } = ${PAGE_STORE_MODULE}; +const ${PAGE_STORE_INITIAL_STATES} = getState(); +export { ${PAGE_STORE_PROVIDER}, ${PAGE_STORE_INITIAL_STATES} };`; + + return importStoreStatement + source + exportStoreProviderStatement; +} + +/** + * Get the page store path which is at the same directory level. + * @param {string} id Route absolute path. + * @returns {string|undefined} + */ +function getPageStorePath(id: string): string | undefined { + const dir = path.dirname(id); + const result = fg.sync(storeFilePattern, { cwd: dir, deep: 1 }); + return result.length ? path.join(dir, result[0]) : undefined; +} + +function isLayout(id: string): boolean { + const extname = path.extname(id); + const idWithoutExtname = id.substring(0, id.length - extname.length); + return idWithoutExtname.endsWith('layout'); +} + +/** + * Check the current route component if there is layout.tsx at the same directory level. + * @param {string} id Route absolute path. + * @returns {boolean} + */ +function isLayoutExisted(id: string): boolean { + const dir = path.dirname(id); + const result = fg.sync('layout.{js,jsx,tsx}', { cwd: dir, deep: 1 }); + return !!result.length; +} + +function getAppStorePath(srcPath: string) { + const result = fg.sync(storeFilePattern, { cwd: srcPath, deep: 1 }); + return result.length ? path.join(srcPath, result[0]) : undefined; +} + +export default plugin; diff --git a/packages/plugin-store/src/runtime.tsx b/packages/plugin-store/src/runtime.tsx new file mode 100644 index 000000000..9a9718d8f --- /dev/null +++ b/packages/plugin-store/src/runtime.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import type { RuntimePlugin, AppProvider, RouteWrapper } from '@ice/types'; +import { createStore, createModel } from '@ice/store'; +import { PAGE_STORE_INITIAL_STATES, PAGE_STORE_PROVIDER } from './constants.js'; +import appStore from '$store'; + +const runtime: RuntimePlugin = async ({ addWrapper, addProvider, useAppContext }) => { + if (appStore && Object.prototype.hasOwnProperty.call(appStore, 'Provider')) { + // Add app store Provider + const StoreProvider: AppProvider = ({ children }) => { + return ( + // TODO: support initialStates: https://github.com/ice-lab/ice-next/issues/395#issuecomment-1210552931 + <appStore.Provider> + {children} + </appStore.Provider> + ); + }; + addProvider(StoreProvider); + } + // page store + const StoreProviderWrapper: RouteWrapper = ({ children, routeId }) => { + const { routeModules } = useAppContext(); + const routeModule = routeModules[routeId]; + if (routeModule[PAGE_STORE_PROVIDER]) { + const Provider = routeModule[PAGE_STORE_PROVIDER]; + const initialStates = routeModule[PAGE_STORE_INITIAL_STATES]; + if (initialStates) { + return <Provider initialStates={initialStates}>{children}</Provider>; + } + return <Provider>{children}</Provider>; + } + return <>{children}</>; + }; + + addWrapper(StoreProviderWrapper, true); +}; + +export { createStore, createModel }; +export default runtime; diff --git a/packages/plugin-store/tsconfig.json b/packages/plugin-store/tsconfig.json new file mode 100644 index 000000000..c98231291 --- /dev/null +++ b/packages/plugin-store/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "src", + "outDir": "esm", + "jsx": "react", + "paths": { + "$store": ["./src/_store"] + } + }, + "include": ["src"] +} \ No newline at end of file diff --git a/packages/rax-compat/build.config.ts b/packages/rax-compat/build.config.ts index 41bbfd1e8..40f7dbbc0 100644 --- a/packages/rax-compat/build.config.ts +++ b/packages/rax-compat/build.config.ts @@ -1,14 +1,6 @@ import { defineConfig } from '@ice/pkg'; export default defineConfig({ - bundle: { - filename: 'rax-compat', - formats: ['umd', 'es2017'], - externals: { - react: 'React', - 'react-dom': 'ReactDOM', - }, - }, transform: { formats: ['esm', 'es2017'], }, diff --git a/packages/rax-compat/package.json b/packages/rax-compat/package.json index 404cc5e2d..0573ee0ca 100644 --- a/packages/rax-compat/package.json +++ b/packages/rax-compat/package.json @@ -1,6 +1,6 @@ { "name": "rax-compat", - "version": "0.1.0", + "version": "0.1.1", "description": "Rax compatible mode, running rax project on the react runtime.", "files": [ "esm", @@ -46,20 +46,16 @@ ], "dependencies": { "@swc/helpers": "^0.4.3", - "style-unit": "^3.0.4", + "style-unit": "^3.0.5", "create-react-class": "^15.7.0" }, "devDependencies": { - "@ice/pkg": "^1.0.0-rc.0", - "@ice/pkg-plugin-docusaurus": "^1.0.0-rc.0", - "@iceworks/spec": "^1.0.0", + "@ice/pkg": "^1.0.0", "@types/rax": "^1.0.8", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", - "eslint": "^7.0.0", "react": "^18.0.0", - "react-dom": "^18.0.0", - "stylelint": "^13.7.2" + "react-dom": "^18.0.0" }, "peerDependencies": { "react": "^18", diff --git a/packages/rax-compat/src/create-element.ts b/packages/rax-compat/src/create-element.ts index 3d23bc5f7..43eb2d843 100644 --- a/packages/rax-compat/src/create-element.ts +++ b/packages/rax-compat/src/create-element.ts @@ -4,12 +4,14 @@ import type { ReactElement, ReactNode, RefObject, + SyntheticEvent, } from 'react'; -import { createElement as _createElement, useEffect, useCallback, useRef } from 'react'; +import { createElement as _createElement, useEffect, useCallback, useRef, useState } from 'react'; import { cached, convertUnit } from 'style-unit'; import { observerElement } from './visibility'; import { isFunction, isObject, isNumber } from './type'; + // https://github.com/alibaba/rax/blob/master/packages/driver-dom/src/index.js // opacity -> opa // fontWeight -> ntw @@ -26,6 +28,27 @@ import { isFunction, isObject, isNumber } from './type'; // borderImageOutset|borderImageSlice|borderImageWidth -> erim const NON_DIMENSIONAL_REG = /opa|ntw|ne[ch]|ex(?:s|g|n|p|$)|^ord|zoo|grid|orp|ows|mnc|^columns$|bs|erim|onit/i; +function createInputCompat(type: string) { + function InputCompat(props: any) { + const { value, onInput, ...rest } = props; + const [v, setV] = useState(value); + const onChange = useCallback((event: SyntheticEvent) => { + setV((event.target as HTMLInputElement).value); + + // Event of onInput should be native event. + onInput && onInput(event.nativeEvent); + }, [onInput]); + + return _createElement(type, { + ...rest, + value: v, + onChange, + }); + } + + return InputCompat; +} + /** * Compat createElement for rax export. * Reference: https://github.com/alibaba/rax/blob/master/packages/rax/src/createElement.js#L13 @@ -58,6 +81,15 @@ export function createElement<P extends { rest.style = compatStyleProps; } + // Setting the value of props makes the component be a controlled component in React. + // But setting the value is same as web in Rax. + // User can modify value of props to modify native input value + // and native input can also modify the value of self in Rax. + // So we should compat input to InputCompat, the same as textarea. + if (type === 'input' || type === 'textarea') { + type = createInputCompat(type); + } + // Compat for visibility events. if (isFunction(onAppear) || isFunction(onDisappear)) { return _createElement( diff --git a/packages/rax-compat/src/intersection-observer.js b/packages/rax-compat/src/intersection-observer.ts similarity index 100% rename from packages/rax-compat/src/intersection-observer.js rename to packages/rax-compat/src/intersection-observer.ts diff --git a/packages/rax-compat/src/typing.d.ts b/packages/rax-compat/src/typing.d.ts new file mode 100644 index 000000000..2178209ae --- /dev/null +++ b/packages/rax-compat/src/typing.d.ts @@ -0,0 +1 @@ +declare module 'style-unit'; \ No newline at end of file diff --git a/packages/rax-compat/tests/createElement.test.tsx b/packages/rax-compat/tests/createElement.test.tsx index 1ba5c9dc1..5b9ea82d3 100644 --- a/packages/rax-compat/tests/createElement.test.tsx +++ b/packages/rax-compat/tests/createElement.test.tsx @@ -51,7 +51,7 @@ describe('createElement', () => { const wrapper = render(createElement( 'div', { - 'data-testid': 'id', + 'data-testid': 'rpxTest', style: { width: '300rpx' } @@ -59,7 +59,21 @@ describe('createElement', () => { str )); - const node = wrapper.queryByTestId('id'); + const node = wrapper.queryByTestId('rpxTest'); expect(node.style.width).toBe('40vw'); }); + + it('should work with value', () => { + const str = 'hello world'; + const wrapper = render(createElement( + 'input', + { + 'data-testid': 'valueTest', + value: str, + }, + )); + + const node = wrapper.queryByTestId('valueTest'); + expect(node.value).toBe(str); + }); }); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index aa152b074..c1f30d6a2 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -36,6 +36,7 @@ "sideEffects": false, "dependencies": { "@ice/jsx-runtime": "^0.1.0", + "consola": "^2.15.3", "history": "^5.3.0", "react-router-dom": "^6.2.2" }, diff --git a/packages/runtime/src/AppData.tsx b/packages/runtime/src/AppData.tsx new file mode 100644 index 000000000..a2e99f9f3 --- /dev/null +++ b/packages/runtime/src/AppData.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import type { AppExport, AppData, RequestContext } from './types.js'; + +const Context = React.createContext<AppData | undefined>(undefined); + +Context.displayName = 'AppDataContext'; + +function useAppData <T = AppData>(): T { + const value = React.useContext(Context); + return value; +} + +const AppDataProvider = Context.Provider; + +/** + * Call the getData of app config. + */ +async function getAppData(appExport: AppExport, requestContext: RequestContext): Promise<AppData> { + const hasGlobalLoader = typeof window !== 'undefined' && (window as any).__ICE_DATA_LOADER__; + + if (hasGlobalLoader) { + const load = (window as any).__ICE_DATA_LOADER__; + return await load('__app'); + } + + if (appExport?.getAppData) { + return await appExport.getAppData(requestContext); + } +} + +export { + getAppData, + useAppData, + AppDataProvider, +}; \ No newline at end of file diff --git a/packages/runtime/src/AppErrorBoundary.tsx b/packages/runtime/src/AppErrorBoundary.tsx index 152ada048..886a1df2b 100644 --- a/packages/runtime/src/AppErrorBoundary.tsx +++ b/packages/runtime/src/AppErrorBoundary.tsx @@ -23,6 +23,7 @@ export default class AppErrorBoundary extends React.Component<Props, State> { public render() { if (this.state.error) { + // TODO: Show the error message and the error stack. return <h1>Something went wrong.</h1>; } diff --git a/packages/runtime/src/Document.tsx b/packages/runtime/src/Document.tsx index 72c422a30..5336a20ae 100644 --- a/packages/runtime/src/Document.tsx +++ b/packages/runtime/src/Document.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; import type { ReactNode } from 'react'; import { useAppContext } from './AppContext.js'; +import { useAppData } from './AppData.js'; import { getMeta, getTitle, getLinks, getScripts } from './routesConfig.js'; import type { AppContext, RouteMatch, AssetsManifest } from './types.js'; +import getCurrentRoutePath from './utils/getCurrentRoutePath.js'; interface DocumentContext { main: ReactNode | null; @@ -19,28 +21,28 @@ function useDocumentContext() { export const DocumentContextProvider = Context.Provider; -export function Meta() { +export function Meta(props) { const { matches, routesConfig } = useAppContext(); const meta = getMeta(matches, routesConfig); return ( <> - {meta.map(item => <meta key={item.name} {...item} />)} - <meta name="ice-meta-count" content={meta.length.toString()} /> + {meta.map(item => <meta key={item.name} {...props} {...item} />)} + <meta {...props} name="ice-meta-count" content={meta.length.toString()} /> </> ); } -export function Title() { +export function Title(props) { const { matches, routesConfig } = useAppContext(); const title = getTitle(matches, routesConfig); return ( - <title>{title} + {title} ); } -export function Links() { +export function Links(props) { const { routesConfig, matches, assetsManifest } = useAppContext(); const routeLinks = getLinks(matches, routesConfig); @@ -52,63 +54,67 @@ export function Links() { <> { routeLinks.map(link => { - const { block, ...props } = link; - return ; + const { block, ...routeLinkProps } = link; + return ; }) } - {styles.map(style => )} + {styles.map(style => )} ); } -export function Scripts() { +export function Scripts(props) { const { routesData, routesConfig, matches, assetsManifest, documentOnly, routeModules, basename } = useAppContext(); + const appData = useAppData(); const routeScripts = getScripts(matches, routesConfig); const pageAssets = getPageAssets(matches, assetsManifest); const entryAssets = getEntryAssets(assetsManifest); - // entry assets need to be load before page assets - const scripts = entryAssets.concat(pageAssets).filter(path => path.indexOf('.js') > -1); + // Page assets need to be load before entry assets, so when call dynamic import won't cause duplicate js chunk loaded. + const scripts = pageAssets.concat(entryAssets).filter(path => path.indexOf('.js') > -1); const matchedIds = matches.map(match => match.route.id); + const routePath = getCurrentRoutePath(matches); const appContext: AppContext = { + appData, routesData, routesConfig, assetsManifest, appConfig: {}, matchedIds, routeModules, + routePath, basename, }; return ( <> {/* - * disable hydration warning for csr. - * initial app data may not equal csr result. + * disable hydration warning for CSR. + * initial app data may not equal CSR result. */}