diff --git a/README.md b/README.md index 39fd988..569dcf3 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,22 @@ export default defineUniPages({ 现在所有的 page 都会被自动发现! -### SFC 自定义块用于路由数据 +### 自定义路由数据 +#### 1、definePage 定义路由数据 +可以在setup中使用 definePage 来定义路由数据。 注意解析definePage是在编译期 不能使用运行期的变量 + +```ts +import { definePage } from '@uni-helper/vite-plugin-uni-pages' + +definePage({ + style:{ + navigationBarTitleText: 'test' + } +}) +``` + +#### 2、SFC 自定义块用于路由数据 通过添加一个 `` 块到 SFC 中来添加路由元数据。这将会在路由生成后直接添加到路由中,并且会覆盖。 你可以使用 `` 来指定一个解析器,或者使用 `routeBlockLang` 选项来设置一个默认的解析器。 diff --git a/packages/core/package.json b/packages/core/package.json index f45f012..b911c7e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -55,7 +55,10 @@ "vite": "^5.0.0" }, "dependencies": { + "@babel/generator": "^7.25.5", + "@babel/types": "^7.25.4", "@uni-helper/uni-env": "^0.1.4", + "@vue-macros/common": "^1.12.2", "@vue/compiler-sfc": "^3.4.38", "chokidar": "^3.6.0", "debug": "^4.3.6", @@ -70,6 +73,7 @@ }, "devDependencies": { "@antfu/utils": "^0.7.10", + "@types/babel__generator": "^7.6.8", "@types/debug": "^4.1.12", "@types/lodash.groupby": "^4.6.9", "@types/node": "^20.15.0", diff --git a/packages/core/src/constant.ts b/packages/core/src/constant.ts index e3eafdb..4eb7704 100644 --- a/packages/core/src/constant.ts +++ b/packages/core/src/constant.ts @@ -4,3 +4,5 @@ export const RESOLVED_MODULE_ID_VIRTUAL = `\0${MODULE_ID_VIRTUAL}` export const OUTPUT_NAME = 'pages.json' export const FILE_EXTENSIONS = ['vue', 'nvue', 'uvue'] + +export const MACRO_DEFINE_PAGE = 'definePage' diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index 610a916..f4e958b 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -1,8 +1,8 @@ import path from 'node:path' import process from 'node:process' +import fs from 'node:fs' import type { FSWatcher } from 'chokidar' import type { Logger, ViteDevServer } from 'vite' -import { normalizePath } from 'vite' import { loadConfig } from 'unconfig' import { slash } from '@antfu/utils' import dbg from 'debug' @@ -13,21 +13,15 @@ import type { PagesConfig } from './config/types' import type { PageMetaDatum, PagePath, ResolvedOptions, SubPageMetaDatum, UserOptions } from './types' import { writeDeclaration } from './declaration' -import { - debug, - invalidatePagesModule, - isTargetFile, - mergePageMetaDataArray, - useCachedPages, -} from './utils' +import { debug, invalidatePagesModule, isTargetFile, mergePageMetaDataArray, useCachedPages } from './utils' import { resolveOptions } from './options' import { checkPagesJsonFile, getPageFiles, readFileSync, writeFileSync } from './files' -import { getRouteBlock, getRouteSfcBlock } from './customBlock' +import { parseSFC } from './customBlock' import { OUTPUT_NAME } from './constant' let lsatPagesJson = '' -const { setCache, hasChanged } = useCachedPages() +const { setCache, hasChanged, parseData } = useCachedPages() export class PageContext { private _server: ViteDevServer | undefined @@ -178,18 +172,11 @@ export class PageContext { async parsePage(page: PagePath): Promise { const { relativePath, absolutePath } = page - const routeSfcBlock = await getRouteSfcBlock(absolutePath) - const routeBlock = await getRouteBlock(absolutePath, routeSfcBlock, this.options) - setCache(absolutePath, routeSfcBlock) const relativePathWithFileName = relativePath.replace(path.extname(relativePath), '') - const pageMetaDatum: PageMetaDatum = { - path: normalizePath(relativePathWithFileName), - type: routeBlock?.attr.type ?? 'page', - } - - if (routeBlock) - Object.assign(pageMetaDatum, routeBlock.content) - + const content = fs.readFileSync(absolutePath, 'utf8') + const sfcDescriptor = await parseSFC(content) + const pageMetaDatum = await parseData(relativePathWithFileName, sfcDescriptor, this.options) + setCache(absolutePath, pageMetaDatum) return pageMetaDatum } @@ -244,9 +231,7 @@ export class PageContext { } async mergePageMetaData() { - const pageMetaData = await this.parsePages(this.pagesPath, 'main', this.pagesGlobConfig?.pages) - - this.pageMetaData = pageMetaData + this.pageMetaData = await this.parsePages(this.pagesPath, 'main', this.pagesGlobConfig?.pages) debug.pages(this.pageMetaData) } @@ -279,7 +264,9 @@ export class PageContext { async updatePagesJSON(filepath?: string) { if (filepath) { - if (!await hasChanged(filepath)) { + const content = fs.readFileSync(filepath, 'utf8') + const sfcDescriptor = await parseSFC(content) + if (!await hasChanged(filepath, sfcDescriptor, this.options)) { debug.cache(`The route block on page ${filepath} did not send any changes, skipping`) return false } diff --git a/packages/core/src/customBlock.ts b/packages/core/src/customBlock.ts index 8df8fb9..6a5ec14 100644 --- a/packages/core/src/customBlock.ts +++ b/packages/core/src/customBlock.ts @@ -1,8 +1,7 @@ -import fs from 'node:fs' import JSON5 from 'json5' import { parse as YAMLParser } from 'yaml' -import { parse as VueParser } from '@vue/compiler-sfc' import type { SFCBlock, SFCDescriptor } from '@vue/compiler-sfc' +import { parse as VueParser } from '@vue/compiler-sfc' import { debug } from './utils' import type { CustomBlock, ResolvedOptions } from './types' @@ -72,13 +71,8 @@ export function parseCustomBlock( } } -export async function getRouteSfcBlock(path: string): Promise { - const content = fs.readFileSync(path, 'utf8') - - const parsedSFC = await parseSFC(content) - const blockStr = parsedSFC?.customBlocks.find(b => b.type === 'route') - - return blockStr +export async function getRouteSfcBlock(parsedSFC: SFCDescriptor): Promise { + return parsedSFC?.customBlocks.find(b => b.type === 'route') } export async function getRouteBlock(path: string, blockStr: SFCBlock | undefined, options: ResolvedOptions): Promise { diff --git a/packages/core/src/definePage.ts b/packages/core/src/definePage.ts new file mode 100644 index 0000000..e9e4b44 --- /dev/null +++ b/packages/core/src/definePage.ts @@ -0,0 +1,91 @@ +import type { SFCDescriptor, SFCScriptBlock } from '@vue/compiler-sfc' +import { babelParse, isCallOf } from '@vue-macros/common' +import * as t from '@babel/types' +import generate from '@babel/generator' +import { MACRO_DEFINE_PAGE } from './constant' +import type { PageMetaDatum } from './types' + +function findMacroWithImports(scriptSetup: SFCScriptBlock | null) { + try { + const empty = { imports: [], macro: undefined } + + if (!scriptSetup) + return empty + + const parsed = babelParse(scriptSetup.content, scriptSetup.lang || 'js', { + plugins: [['importAttributes', { deprecatedAssertSyntax: true }]], + }) + + const stmts = parsed.body + + const nodes = stmts + .map((raw: t.Node) => { + let node = raw + if (raw.type === 'ExpressionStatement') + node = raw.expression + return isCallOf(node, MACRO_DEFINE_PAGE) ? node : undefined + }) + .filter((node): node is t.CallExpression => !!node) + + if (!nodes.length) + return empty + + if (nodes.length > 1) + throw new Error(`duplicate ${MACRO_DEFINE_PAGE}() call`) + + const macro = nodes[0] + + const [arg] = macro.arguments + + if (arg && !t.isFunctionExpression(arg) && !t.isArrowFunctionExpression(arg) && !t.isObjectExpression(arg)) + throw new Error(`${MACRO_DEFINE_PAGE}() only accept argument in function or object`) + + const imports = stmts + .map((node: t.Node) => (node.type === 'ImportDeclaration') ? node : undefined) + .filter((node): node is t.ImportDeclaration => !!node) + + return { + imports, + macro, + } + } catch (error) { + throw new Error(`Error parsing script setup content: ${error.message}`) + } +} + +export async function readPageOptionsFromMacro(sfc: SFCDescriptor) { + const { macro } = findMacroWithImports(sfc.scriptSetup) + + if (!macro) + return {} + + if (sfc?.customBlocks.find(b => b.type === 'route')) + throw new Error(`mixed ${MACRO_DEFINE_PAGE}() and is not allowed`) + + const [arg] = macro.arguments + + if (!arg) + return {} + + let code + if (typeof generate === 'function') { + code = generate(arg).code + } + else { + code = (generate as any).default(arg).code + } + + const script = t.isFunctionExpression(arg) || t.isArrowFunctionExpression(arg) + ? `return await Promise.resolve((${code})())` + : `return ${code}` + + const AsyncFunction = Object.getPrototypeOf(async () => { }).constructor + + const fn = new AsyncFunction(script) + + return await fn() as Partial +} + +export function findMacro(scriptSetup: SFCScriptBlock | null) { + return findMacroWithImports(scriptSetup).macro +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9c6e26d..815caf4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,7 @@ import type { Plugin } from 'vite' import { createLogger } from 'vite' import MagicString from 'magic-string' import chokidar from 'chokidar' +import { parse as parseSFC } from '@vue/compiler-sfc' import type { UserOptions } from './types' import { PageContext } from './context' import { @@ -13,6 +14,7 @@ import { RESOLVED_MODULE_ID_VIRTUAL, } from './constant' import { checkPagesJsonFile } from './files' +import { findMacro } from './definePage' export * from './config' export * from './types' @@ -91,6 +93,13 @@ export function VitePluginUniPages(userOptions: UserOptions = {}): Plugin { s.remove(index, index + length) } + const { descriptor: sfc } = parseSFC(code, { filename: id }) + const macro = findMacro(sfc.scriptSetup) + if (macro) { + const setupOffset = sfc.scriptSetup!.loc.start.offset + s.remove(setupOffset + macro.start!, setupOffset + macro.end!) + } + if (s.hasChanged()) { return { code: s.toString(), diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f727612..10b2b3a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -140,3 +140,8 @@ export interface SubPageMetaDatum { root: string pages: PageMetaDatum[] } + +export function definePage(_options: Partial< + PageMetaDatum +>) { +} diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 27b3eec..28c9971 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1,10 +1,11 @@ import Debug from 'debug' import { type ModuleNode, type ViteDevServer, normalizePath } from 'vite' import groupBy from 'lodash.groupby' -import type { SFCBlock } from '@vue/compiler-sfc' +import type { SFCDescriptor } from '@vue/compiler-sfc' import { FILE_EXTENSIONS, RESOLVED_MODULE_ID_VIRTUAL } from './constant' -import type { PageMetaDatum } from './types' -import { getRouteSfcBlock } from './customBlock' +import type { PageMetaDatum, ResolvedOptions } from './types' +import { getRouteBlock, getRouteSfcBlock } from './customBlock' +import { readPageOptionsFromMacro } from './definePage' export function invalidatePagesModule(server: ViteDevServer) { const { moduleGraph } = server @@ -60,26 +61,35 @@ export function mergePageMetaDataArray(pageMetaData: PageMetaDatum[]) { export function useCachedPages() { const pages = new Map() - function parseData(block?: SFCBlock) { - return { - content: block?.loc.source.trim() ?? '', - attr: block?.attrs ?? '', + async function parseData(filePath: string, sfcDescriptor: SFCDescriptor, options: ResolvedOptions) { + const routeSfcBlock = await getRouteSfcBlock(sfcDescriptor) + const routeBlock = await getRouteBlock(filePath, routeSfcBlock, options) + const pageMetaDatum: PageMetaDatum = { + path: normalizePath(filePath), + type: routeBlock?.attr.type ?? 'page', } + const definePageData = await readPageOptionsFromMacro(sfcDescriptor) + Object.assign(pageMetaDatum, definePageData) + return pageMetaDatum } - function setCache(filePath: string, routeBlock?: SFCBlock) { - pages.set(filePath, JSON.stringify(parseData(routeBlock))) + function setCache(filePath: string, pageMetaDatum?: PageMetaDatum) { + debug.cache('filePath', filePath) + const { path, ...rest } = pageMetaDatum ?? {} + pages.set(filePath, JSON.stringify(rest)) } - async function hasChanged(filePath: string, routeBlock?: SFCBlock) { - if (!routeBlock) - routeBlock = await getRouteSfcBlock(normalizePath(filePath)) - - return !pages.has(filePath) || JSON.stringify(parseData(routeBlock)) !== pages.get(filePath) + async function hasChanged(filePath: string, sfcDescriptor: SFCDescriptor, options: ResolvedOptions) { + const pageMetaDatum = await parseData(filePath, sfcDescriptor, options) + const { path, ...rest } = pageMetaDatum ?? {} + const changed = !pages.has(filePath) || JSON.stringify(rest) !== pages.get(filePath) + debug.cache('page changed', changed) + return changed } return { setCache, hasChanged, + parseData, } } diff --git a/packages/playground/src/pages/test-define-page.vue b/packages/playground/src/pages/test-define-page.vue new file mode 100644 index 0000000..062049d --- /dev/null +++ b/packages/playground/src/pages/test-define-page.vue @@ -0,0 +1,17 @@ + + + + diff --git a/test/parser.spec.ts b/test/parser.spec.ts index fa09653..f61049e 100644 --- a/test/parser.spec.ts +++ b/test/parser.spec.ts @@ -1,6 +1,7 @@ import { resolve } from 'node:path' +import fs from 'node:fs' import { describe, expect, it } from 'vitest' -import { getRouteBlock, getRouteSfcBlock, resolveOptions } from '../packages/core/src/index' +import { getRouteBlock, getRouteSfcBlock, parseSFC, resolveOptions } from '../packages/core/src/index' const options = resolveOptions({}) const pagesJson = 'packages/playground/src/pages/test-json.vue' @@ -9,7 +10,9 @@ const pagesYaml = 'packages/playground/src/pages/test-yaml.vue' describe('parser', () => { it('custom block', async () => { const path = resolve(pagesJson) - const str = await getRouteSfcBlock(path) + const content = fs.readFileSync(path, 'utf8') + const sfcDescriptor = await parseSFC(content) + const str = await getRouteSfcBlock(sfcDescriptor) const routeBlock = await getRouteBlock(path, str, options) expect(routeBlock).toMatchInlineSnapshot(` { @@ -31,7 +34,9 @@ describe('parser', () => { it('yaml comment', async () => { const path = resolve(pagesYaml) - const str = await getRouteSfcBlock(path) + const content = fs.readFileSync(path, 'utf8') + const sfcDescriptor = await parseSFC(content) + const str = await getRouteSfcBlock(sfcDescriptor) const routeBlock = await getRouteBlock(path, str, options) expect(routeBlock).toMatchInlineSnapshot(` {