diff --git a/examples/with-hooks-store/ice.config.mts b/examples/with-hooks-store/ice.config.mts new file mode 100644 index 000000000..9b4b21c02 --- /dev/null +++ b/examples/with-hooks-store/ice.config.mts @@ -0,0 +1,8 @@ +import { defineConfig } from '@ice/app'; +import store from '@ice/plugin-hooks-store'; + +export default defineConfig({ + plugins: [ + store(), + ], +}); diff --git a/examples/with-hooks-store/package.json b/examples/with-hooks-store/package.json new file mode 100644 index 000000000..3b02b53cc --- /dev/null +++ b/examples/with-hooks-store/package.json @@ -0,0 +1,21 @@ +{ + "name": "with-hook-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-hooks-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-hooks-store/public/favicon.ico b/examples/with-hooks-store/public/favicon.ico new file mode 100644 index 000000000..a2605c57e Binary files /dev/null and b/examples/with-hooks-store/public/favicon.ico differ diff --git a/examples/with-hooks-store/src/app.tsx b/examples/with-hooks-store/src/app.tsx new file mode 100644 index 000000000..c1664902e --- /dev/null +++ b/examples/with-hooks-store/src/app.tsx @@ -0,0 +1,3 @@ +import { defineAppConfig } from 'ice'; + +export default defineAppConfig({}); diff --git a/examples/with-hooks-store/src/document.tsx b/examples/with-hooks-store/src/document.tsx new file mode 100644 index 000000000..a61df501e --- /dev/null +++ b/examples/with-hooks-store/src/document.tsx @@ -0,0 +1,23 @@ +import { Meta, Title, Links, Main, Scripts } from 'ice'; + +function Document() { + return ( + + + + + + + + + <Links /> + </head> + <body> + <Main /> + <Scripts /> + </body> + </html> + ); +} + +export default Document; diff --git a/examples/with-hooks-store/src/models/useTodo.ts b/examples/with-hooks-store/src/models/useTodo.ts new file mode 100644 index 000000000..ac76c0d86 --- /dev/null +++ b/examples/with-hooks-store/src/models/useTodo.ts @@ -0,0 +1,12 @@ +import { useState } from 'react'; + +function useTodo() { + const [todos, setTodos] = useState([{ name: 'ICE', id: 'ICE' }]); + + return { + todos, + setTodos, + }; +} + +export default useTodo; diff --git a/examples/with-hooks-store/src/pages/about/index.tsx b/examples/with-hooks-store/src/pages/about/index.tsx new file mode 100644 index 000000000..0ad5226d8 --- /dev/null +++ b/examples/with-hooks-store/src/pages/about/index.tsx @@ -0,0 +1,16 @@ +import { Link } from 'ice'; +import store from './store'; + +function About() { + const { name, age } = store.useHook('useUser'); + return ( + <> + <div>name: {name}</div> + <div>age: {age}</div> + + <Link to="/">Home</Link> + </> + ); +} + +export default About; diff --git a/examples/with-hooks-store/src/pages/about/layout.tsx b/examples/with-hooks-store/src/pages/about/layout.tsx new file mode 100644 index 000000000..b76c2b5f2 --- /dev/null +++ b/examples/with-hooks-store/src/pages/about/layout.tsx @@ -0,0 +1,9 @@ +import { Outlet } from 'ice'; + +export default function layout() { + return ( + <> + <Outlet /> + </> + ); +} diff --git a/examples/with-hooks-store/src/pages/about/models/useUser.ts b/examples/with-hooks-store/src/pages/about/models/useUser.ts new file mode 100644 index 000000000..18c1cd2c2 --- /dev/null +++ b/examples/with-hooks-store/src/pages/about/models/useUser.ts @@ -0,0 +1,6 @@ +export default function useUser() { + return { + name: 'ICE 3', + age: 5, + }; +} diff --git a/examples/with-hooks-store/src/pages/about/store.ts b/examples/with-hooks-store/src/pages/about/store.ts new file mode 100644 index 000000000..8092fa82d --- /dev/null +++ b/examples/with-hooks-store/src/pages/about/store.ts @@ -0,0 +1,4 @@ +import { createStore } from 'ice'; +import useUser from './models/useUser'; + +export default createStore({ useUser }); diff --git a/examples/with-hooks-store/src/pages/index.tsx b/examples/with-hooks-store/src/pages/index.tsx new file mode 100644 index 000000000..09f44ed53 --- /dev/null +++ b/examples/with-hooks-store/src/pages/index.tsx @@ -0,0 +1,28 @@ +import { Link } from 'ice'; +import pageStore from './store'; +import appStore from '@/store'; + +function Home() { + const { todos } = appStore.useHook('useTodo'); + const { count, increment, decrement } = pageStore.useHook('useCounter'); + + return ( + <> + <div> + ToDo List + <ul id="todos"> + {todos.map(todo => (<li key={todo.id}>{todo.name}</li>))} + </ul> + </div> + <div> + <button type="button" onClick={() => increment()}>+</button> + <span>{count}</span> + <button type="button" onClick={() => decrement()}>-</button> + </div> + <Link to="/about">About</Link> + </> + ); +} + + +export default Home; diff --git a/examples/with-hooks-store/src/pages/models/useCounter.ts b/examples/with-hooks-store/src/pages/models/useCounter.ts new file mode 100644 index 000000000..ab10d03a1 --- /dev/null +++ b/examples/with-hooks-store/src/pages/models/useCounter.ts @@ -0,0 +1,15 @@ +import { useState } from 'react'; + +function useCounter() { + const [count, setCount] = useState(0); + const increment = () => setCount(count + 1); + const decrement = () => setCount(count - 1); + + return { + count, + increment, + decrement, + }; +} + +export default useCounter; diff --git a/examples/with-hooks-store/src/pages/store.ts b/examples/with-hooks-store/src/pages/store.ts new file mode 100644 index 000000000..8ad7c8e41 --- /dev/null +++ b/examples/with-hooks-store/src/pages/store.ts @@ -0,0 +1,4 @@ +import { createStore } from 'ice'; +import useCounter from './models/useCounter'; + +export default createStore({ useCounter }); diff --git a/examples/with-hooks-store/src/store.ts b/examples/with-hooks-store/src/store.ts new file mode 100644 index 000000000..a7a7bc3f2 --- /dev/null +++ b/examples/with-hooks-store/src/store.ts @@ -0,0 +1,4 @@ +import { createStore } from 'ice'; +import useTodo from './models/useTodo'; + +export default createStore({ useTodo }); diff --git a/examples/with-hooks-store/tsconfig.json b/examples/with-hooks-store/tsconfig.json new file mode 100644 index 000000000..7f2f2ffce --- /dev/null +++ b/examples/with-hooks-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/packages/plugin-hooks-store/CHANGELOG.md b/packages/plugin-hooks-store/CHANGELOG.md new file mode 100644 index 000000000..eb29e3cbe --- /dev/null +++ b/packages/plugin-hooks-store/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## 1.0.0 + +- feat: init plugin diff --git a/packages/plugin-hooks-store/README.md b/packages/plugin-hooks-store/README.md new file mode 100644 index 000000000..50c1be6f8 --- /dev/null +++ b/packages/plugin-hooks-store/README.md @@ -0,0 +1,16 @@ +# @ice/plugin-hooks-store + +A plugin of lightweight React Hooks state management used in framework `ICE`. + +## Usage + +```ts +import { defineConfig } from '@ice/app'; +import store from '@ice/plugin-hooks-store'; + +export default defineConfig({ + plugins: [ + store(), + ], +}); +``` diff --git a/packages/plugin-hooks-store/package.json b/packages/plugin-hooks-store/package.json new file mode 100644 index 000000000..56d538e42 --- /dev/null +++ b/packages/plugin-hooks-store/package.json @@ -0,0 +1,56 @@ +{ + "name": "@ice/plugin-hooks-store", + "version": "1.0.0", + "description": "A plugin of lightweight React Hooks state management based on @ice/hooks-store.", + "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" + }, + "./api": { + "types": "./esm/api.d.ts", + "import": "./esm/api.js", + "default": "./esm/api.js" + } + }, + "main": "./esm/index.js", + "types": "./esm/index.d.ts", + "files": [ + "esm", + "!esm/**/*.map" + ], + "dependencies": { + "@ice/hooks-store": "^1.0.0-alpha.0", + "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", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "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-hook-store" + }, + "scripts": { + "watch": "tsc -w", + "build": "tsc" + } +} \ No newline at end of file diff --git a/packages/plugin-hooks-store/src/_store.ts b/packages/plugin-hooks-store/src/_store.ts new file mode 100644 index 000000000..e9ffe6434 --- /dev/null +++ b/packages/plugin-hooks-store/src/_store.ts @@ -0,0 +1,10 @@ +/** + * This file which is imported by the runtime.tsx, is to avoid TS error. + */ +import { createStore } from '@ice/hooks-store'; + +const models = {}; + +const store: ReturnType<typeof createStore> = createStore(models); + +export default store; diff --git a/packages/plugin-hooks-store/src/api.ts b/packages/plugin-hooks-store/src/api.ts new file mode 100644 index 000000000..bda11584e --- /dev/null +++ b/packages/plugin-hooks-store/src/api.ts @@ -0,0 +1 @@ +export { createStore } from '@ice/hooks-store'; diff --git a/packages/plugin-hooks-store/src/constants.ts b/packages/plugin-hooks-store/src/constants.ts new file mode 100644 index 000000000..4c487edc6 --- /dev/null +++ b/packages/plugin-hooks-store/src/constants.ts @@ -0,0 +1,2 @@ +export const PAGE_STORE_MODULE = '__PAGE_STORE__'; +export const PAGE_STORE_PROVIDER = '__PAGE_STORE_PROVIDER__'; diff --git a/packages/plugin-hooks-store/src/index.ts b/packages/plugin-hooks-store/src/index.ts new file mode 100644 index 000000000..af27b8a83 --- /dev/null +++ b/packages/plugin-hooks-store/src/index.ts @@ -0,0 +1,113 @@ +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import fg from 'fast-glob'; +import type { Config, Plugin } from '@ice/types'; +import micromatch from 'micromatch'; +import { PAGE_STORE_MODULE, PAGE_STORE_PROVIDER } from './constants.js'; + +const storeFilePattern = '**/store.{js,ts}'; +const ignoreStoreFilePatterns = ['**/models/**', storeFilePattern]; + +const plugin: Plugin = () => ({ + name: '@ice/plugin-hook-store', + setup: ({ onGetConfig, modifyUserConfig, generator, context: { rootDir, userConfig } }) => { + 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 }), + ]; + return config; + }); + + // Export store api: createStore, createModel from `.ice/index.ts`. + generator.addExport({ + specifier: ['createStore'], + source: '@ice/plugin-hooks-store/api', + type: false, + }); + }, + runtime: path.join(path.dirname(fileURLToPath(import.meta.url)), 'runtime.js'), +}); + + +function exportStoreProviderPlugin({ pageDir }: { pageDir: string }): 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); + } + } + return source; + }, + }; +} + +function exportPageStore(source: string) { + const importStoreStatement = `import ${PAGE_STORE_MODULE} from './store';\n`; + const exportStoreProviderStatement = ` +const { Provider: ${PAGE_STORE_PROVIDER} } = ${PAGE_STORE_MODULE}; +export { ${PAGE_STORE_PROVIDER} };`; + + 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-hooks-store/src/runtime.tsx b/packages/plugin-hooks-store/src/runtime.tsx new file mode 100644 index 000000000..be0817c13 --- /dev/null +++ b/packages/plugin-hooks-store/src/runtime.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { createStore } from '@ice/hooks-store'; +import type { RuntimePlugin, AppProvider, RouteWrapper } from '@ice/types'; +import { PAGE_STORE_PROVIDER } from './constants.js'; +import appStore from '$store'; + +const runtime: RuntimePlugin = async ({ addProvider, useAppContext, addWrapper }) => { + 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]; + return <Provider>{children}</Provider>; + } + return <>{children}</>; + }; + + addWrapper(StoreProviderWrapper, true); +}; + +export { createStore }; +export default runtime; diff --git a/packages/plugin-hooks-store/tsconfig.json b/packages/plugin-hooks-store/tsconfig.json new file mode 100644 index 000000000..c98231291 --- /dev/null +++ b/packages/plugin-hooks-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/pnpm-lock.yaml b/pnpm-lock.yaml index 479b517dd..9e851ec1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,6 +279,25 @@ importers: '@types/react': 18.0.8 '@types/react-dom': 18.0.3 + examples/with-hooks-store: + specifiers: + '@ice/app': workspace:* + '@ice/plugin-hooks-store': workspace:* + '@ice/runtime': workspace:* + '@types/react': ^18.0.0 + '@types/react-dom': ^18.0.0 + browserslist: ^4.19.3 + regenerator-runtime: ^0.13.9 + dependencies: + '@ice/app': link:../../packages/ice + '@ice/runtime': link:../../packages/runtime + devDependencies: + '@ice/plugin-hooks-store': link:../../packages/plugin-hooks-store + '@types/react': 18.0.17 + '@types/react-dom': 18.0.6 + browserslist: 4.21.3 + regenerator-runtime: 0.13.9 + examples/with-pha: specifiers: '@ice/app': workspace:* @@ -626,6 +645,31 @@ importers: devDependencies: '@ice/types': link:../types + packages/plugin-hooks-store: + specifiers: + '@ice/hooks-store': ^1.0.0-alpha.0 + '@ice/types': ^1.0.0 + '@types/micromatch': ^4.0.2 + '@types/react': ^18.0.0 + '@types/react-dom': ^18.0.0 + fast-glob: ^3.2.11 + micromatch: ^4.0.5 + react: ^18.2.0 + react-dom: ^18.2.0 + regenerator-runtime: ^0.13.9 + dependencies: + '@ice/hooks-store': 1.0.0-alpha.0_react@18.2.0 + fast-glob: 3.2.11 + micromatch: 4.0.5 + devDependencies: + '@ice/types': link:../types + '@types/micromatch': 4.0.2 + '@types/react': 18.0.17 + '@types/react-dom': 18.0.6 + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + regenerator-runtime: 0.13.9 + packages/plugin-moment-locales: specifiers: '@ice/types': ^1.0.0 @@ -5282,6 +5326,14 @@ packages: resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} dev: true + /@ice/hooks-store/1.0.0-alpha.0_react@18.2.0: + resolution: {integrity: sha512-ElEkwhiyZBfb5TdbpmbknSoXWIK3365mmqMx+i4VU5MSXgb9ciAfcT2tRo8poni7TFHtqtZe/cyBQRTnx+IN3A==} + peerDependencies: + react: ^16.8 || ^17 || ^18 + dependencies: + react: 18.2.0 + dev: false + /@ice/pkg-plugin-component/1.0.0: resolution: {integrity: sha512-Mff6Em1RwY2NOZgciQyy2NXL65u9ebHV2Jqb5746vUVEh0l7Xpokb+3djV1pKcgED4wU8pSuSQ6jj+hXd9Ht1Q==} dependencies: