From dd37402d3867a13c5bd37c6e4dde843637790689 Mon Sep 17 00:00:00 2001 From: weareoutman Date: Fri, 11 Oct 2024 19:52:10 +0800 Subject: [PATCH 1/4] feat(): support segues --- packages/runtime/src/internal/Renderer.ts | 1 + .../runtime/src/internal/fulfilStoryboard.ts | 201 +++++++++++++++++- packages/types/src/manifest.ts | 1 + 3 files changed, 202 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/internal/Renderer.ts b/packages/runtime/src/internal/Renderer.ts index c69c11e309..d42843ad9f 100644 --- a/packages/runtime/src/internal/Renderer.ts +++ b/packages/runtime/src/internal/Renderer.ts @@ -366,6 +366,7 @@ async function legacyRenderBrick( dataSource: brickIf, // `permissionsPreCheck` maybe required before computing `if`. permissionsPreCheck, + iid: brickConf.iid, slots: { "": { type: "bricks", diff --git a/packages/runtime/src/internal/fulfilStoryboard.ts b/packages/runtime/src/internal/fulfilStoryboard.ts index ea9b579ae9..91a9b2aef0 100644 --- a/packages/runtime/src/internal/fulfilStoryboard.ts +++ b/packages/runtime/src/internal/fulfilStoryboard.ts @@ -1,6 +1,17 @@ -import type { RuntimeStoryboard } from "@next-core/types"; +import type { + BrickConf, + BrickEventHandler, + BrickEventsMap, + BuiltinBrickEventHandler, + RouteConf, + RouteConfOfBricks, + RuntimeStoryboard, +} from "@next-core/types"; +import { isEvaluable } from "@next-core/cook"; +import { uniqueId } from "lodash"; import { hooks } from "./Runtime.js"; import { registerAppI18n } from "./registerAppI18n.js"; +import { isBuiltinHandler } from "./bindListeners.js"; export async function fulfilStoryboard(storyboard: RuntimeStoryboard) { if (storyboard.$$fulfilled) { @@ -15,8 +26,196 @@ export async function fulfilStoryboard(storyboard: RuntimeStoryboard) { async function doFulfilStoryboard(storyboard: RuntimeStoryboard) { await hooks?.fulfilStoryboard?.(storyboard); registerAppI18n(storyboard); + initializeSeguesForRoutes(storyboard.routes); Object.assign(storyboard, { $$fulfilled: true, $$fulfilling: null, }); } + +interface SegueConf { + by: "drawer" | "modal"; + route?: { + path: string; + params: SegueRouteParam[]; + }; + eventsMapping?: Record; + events?: BrickEventsMap; +} + +interface SegueRouteParam { + key: string; + value: string; +} + +function initializeSeguesForRoutes(routes: RouteConf[]) { + for (const route of routes) { + if (route.type !== "redirect" && route.type !== "routes") { + initializeSeguesForBricks(route.bricks, route); + } + } +} + +function initializeSeguesForBricks( + bricks: BrickConf[], + routeParent: RouteConfOfBricks +) { + for (const brick of bricks) { + if (brick.events) { + for (const [eventType, handlers] of Object.entries(brick.events)) { + if (Array.isArray(handlers)) { + handlers.forEach((handler, index) => { + if (isBuiltinHandler(handler) && handler.action === "segue.go") { + replaceSegues(handler, handlers, index, routeParent); + } + }); + } else if ( + isBuiltinHandler(handlers) && + handlers.action === "segue.go" + ) { + replaceSegues(handlers, brick.events, eventType, routeParent); + } + } + } + + if (brick.slots) { + for (const slotConf of Object.values(brick.slots)) { + if (slotConf.type === "routes") { + initializeSeguesForRoutes(slotConf.routes); + } else { + initializeSeguesForBricks(slotConf.bricks, routeParent); + } + } + } else if (Array.isArray(brick.children)) { + initializeSeguesForBricks(brick.children, routeParent); + } + } +} + +function replaceSegues( + handler: BuiltinBrickEventHandler, + handlers: BrickEventsMap | BrickEventHandler[], + key: string | number, + routeParent: RouteConfOfBricks +) { + let segueConf: SegueConf | undefined; + let segueTarget: string | undefined; + if ( + Array.isArray(handler.args) && + ((segueTarget = handler.args[0] as string), + typeof segueTarget === "string") && + (segueConf = handler.args[1] as SegueConf) + ) { + switch (segueConf.by) { + case "drawer": { + if (segueConf.route) { + const { params, path } = segueConf.route; + const targetUrlExpr = path.replace(/:(\w+)/g, (_, key) => { + const param = params.find((param) => param.key === key); + return param + ? typeof param.value === "string" && isEvaluable(param.value) + ? `\${${param.value.replace(/^\s*<%[~=]?\s|\s%>\s*$/g, "")}}` + : String(param.value).replace(/`/g, "\\`") + : `\${PATH.${key}}`; + }); + (handlers as BrickEventsMap)[key] = { + action: "history.push", + args: [`<% \`${targetUrlExpr}\` %>`], + }; + const drawerId = uniqueId("internal-segue-drawer-"); + const drawerTarget = `#${drawerId}`; + routeParent.bricks.push({ + brick: "eo-drawer", + portal: true, + properties: { + id: drawerId, + customTitle: "Detail", + }, + events: { + close: { + action: "history.push", + args: [ + `<% \`${routeParent.path.replace(/:(\w)+/g, "${PATH.$1}")}\` %>`, + ], + }, + }, + slots: { + "": { + type: "routes", + routes: [ + { + path, + exact: true, + bricks: [ + { + brick: segueTarget, + properties: Object.fromEntries( + params.map((param) => [ + param.key, + `<% PATH.${param.key} %>`, + ]) + ), + lifeCycle: { + onMount: { + target: drawerTarget, + method: "open", + }, + onUnmount: { + target: drawerTarget, + method: "close", + }, + }, + }, + ], + }, + ], + }, + }, + }); + } + break; + } + + case "modal": { + const modalId = uniqueId("internal-segue-modal-"); + const modalTarget = `#${modalId}`; + const sceneId = uniqueId("internal-segue-scene-"); + const sceneTarget = `#${sceneId}`; + (handlers as BrickEventsMap)[key] = { + target: modalTarget, + method: "open", + }; + routeParent.bricks.push({ + brick: "eo-modal", + portal: true, + properties: { + id: modalId, + modalTitle: "Create", + closeWhenConfirm: false, + }, + events: segueConf.eventsMapping + ? Object.fromEntries( + Object.entries(segueConf.eventsMapping).map(([from, to]) => [ + from, + { + target: sceneTarget, + method: to, + }, + ]) + ) + : undefined, + children: [ + { + brick: segueTarget, + properties: { + id: sceneId, + }, + events: segueConf.events, + }, + ], + }); + break; + } + } + } +} diff --git a/packages/types/src/manifest.ts b/packages/types/src/manifest.ts index 908063ff41..fda1eaccb3 100644 --- a/packages/types/src/manifest.ts +++ b/packages/types/src/manifest.ts @@ -926,6 +926,7 @@ export interface BuiltinBrickEventHandler { | "history.unblock" // Segues + | "segue.go" // | "segue.push" // | "segue.replace" From b7bb1209d012b89161d9c9bc8c9b46722d31a8c4 Mon Sep 17 00:00:00 2001 From: Shenwei Wang Date: Fri, 11 Oct 2024 20:07:43 +0800 Subject: [PATCH 2/4] chore(segue): fix a regex to match full param name Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/runtime/src/internal/fulfilStoryboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/internal/fulfilStoryboard.ts b/packages/runtime/src/internal/fulfilStoryboard.ts index 91a9b2aef0..cebbf2d1c9 100644 --- a/packages/runtime/src/internal/fulfilStoryboard.ts +++ b/packages/runtime/src/internal/fulfilStoryboard.ts @@ -135,7 +135,7 @@ function replaceSegues( close: { action: "history.push", args: [ - `<% \`${routeParent.path.replace(/:(\w)+/g, "${PATH.$1}")}\` %>`, + `<% \`${routeParent.path.replace(/:(\w+)/g, "${PATH.$1}")}\` %>`, ], }, }, From 38563b79789b23e69cc35c07d128425d6146c698 Mon Sep 17 00:00:00 2001 From: Shenwei Wang Date: Sat, 12 Oct 2024 09:39:18 +0800 Subject: [PATCH 3/4] Fix code scanning alert no. 34: Incomplete string escaping or encoding Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- packages/runtime/src/internal/fulfilStoryboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/internal/fulfilStoryboard.ts b/packages/runtime/src/internal/fulfilStoryboard.ts index cebbf2d1c9..fc8d20545c 100644 --- a/packages/runtime/src/internal/fulfilStoryboard.ts +++ b/packages/runtime/src/internal/fulfilStoryboard.ts @@ -115,7 +115,7 @@ function replaceSegues( return param ? typeof param.value === "string" && isEvaluable(param.value) ? `\${${param.value.replace(/^\s*<%[~=]?\s|\s%>\s*$/g, "")}}` - : String(param.value).replace(/`/g, "\\`") + : String(param.value).replace(/[`\\]/g, "\\$&") : `\${PATH.${key}}`; }); (handlers as BrickEventsMap)[key] = { From ffd607bc18758d42eacc46fa168e55c6686df277 Mon Sep 17 00:00:00 2001 From: weareoutman Date: Thu, 17 Oct 2024 16:39:18 +0800 Subject: [PATCH 4/4] WIP --- .../runtime/src/internal/fulfilStoryboard.ts | 169 +++-- packages/utils/src/storyboard/index.ts | 1 + packages/utils/src/storyboard/parser/index.ts | 3 + .../utils/src/storyboard/parser/interfaces.ts | 271 +++++++ .../src/storyboard/parser/parser.spec.ts | 698 ++++++++++++++++++ .../utils/src/storyboard/parser/parser.ts | 504 +++++++++++++ .../src/storyboard/parser/traverse.spec.ts | 184 +++++ .../utils/src/storyboard/parser/traverse.ts | 131 ++++ 8 files changed, 1884 insertions(+), 77 deletions(-) create mode 100644 packages/utils/src/storyboard/parser/index.ts create mode 100644 packages/utils/src/storyboard/parser/interfaces.ts create mode 100644 packages/utils/src/storyboard/parser/parser.spec.ts create mode 100644 packages/utils/src/storyboard/parser/parser.ts create mode 100644 packages/utils/src/storyboard/parser/traverse.spec.ts create mode 100644 packages/utils/src/storyboard/parser/traverse.ts diff --git a/packages/runtime/src/internal/fulfilStoryboard.ts b/packages/runtime/src/internal/fulfilStoryboard.ts index fc8d20545c..29b2bb9953 100644 --- a/packages/runtime/src/internal/fulfilStoryboard.ts +++ b/packages/runtime/src/internal/fulfilStoryboard.ts @@ -3,15 +3,23 @@ import type { BrickEventHandler, BrickEventsMap, BuiltinBrickEventHandler, - RouteConf, RouteConfOfBricks, RuntimeStoryboard, + Storyboard, } from "@next-core/types"; import { isEvaluable } from "@next-core/cook"; +import { hasOwnProperty } from "@next-core/utils/general"; +import { + parseStoryboard, + traverse, + parseEvents, + type StoryboardNodeEvent, + type StoryboardNodeRoute, +} from "@next-core/utils/storyboard"; import { uniqueId } from "lodash"; import { hooks } from "./Runtime.js"; import { registerAppI18n } from "./registerAppI18n.js"; -import { isBuiltinHandler } from "./bindListeners.js"; +import { isBuiltinHandler, isCustomHandler } from "./bindListeners.js"; export async function fulfilStoryboard(storyboard: RuntimeStoryboard) { if (storyboard.$$fulfilled) { @@ -26,70 +34,58 @@ export async function fulfilStoryboard(storyboard: RuntimeStoryboard) { async function doFulfilStoryboard(storyboard: RuntimeStoryboard) { await hooks?.fulfilStoryboard?.(storyboard); registerAppI18n(storyboard); - initializeSeguesForRoutes(storyboard.routes); + // initializeSeguesForRoutes(storyboard.routes); + initializeSegues(storyboard); Object.assign(storyboard, { $$fulfilled: true, $$fulfilling: null, }); } +type SceneConf = Pick; + interface SegueConf { - by: "drawer" | "modal"; + type: "drawer" | "modal"; route?: { path: string; - params: SegueRouteParam[]; + params: Record; }; - eventsMapping?: Record; - events?: BrickEventsMap; -} - -interface SegueRouteParam { - key: string; - value: string; + modal?: SceneConf; + scene?: SceneConf; } -function initializeSeguesForRoutes(routes: RouteConf[]) { - for (const route of routes) { - if (route.type !== "redirect" && route.type !== "routes") { - initializeSeguesForBricks(route.bricks, route); - } - } -} +function initializeSegues(storyboard: Storyboard) { + const ast = parseStoryboard(storyboard); -function initializeSeguesForBricks( - bricks: BrickConf[], - routeParent: RouteConfOfBricks -) { - for (const brick of bricks) { - if (brick.events) { - for (const [eventType, handlers] of Object.entries(brick.events)) { - if (Array.isArray(handlers)) { - handlers.forEach((handler, index) => { - if (isBuiltinHandler(handler) && handler.action === "segue.go") { - replaceSegues(handler, handlers, index, routeParent); - } - }); - } else if ( - isBuiltinHandler(handlers) && - handlers.action === "segue.go" - ) { - replaceSegues(handlers, brick.events, eventType, routeParent); + traverse(ast, (node, nodePath) => { + switch (node.type) { + case "EventHandler": + if (isBuiltinHandler(node.raw) && node.raw.action === "segue.go") { + const parent = nodePath[nodePath.length - 1] as StoryboardNodeEvent; + const routeParent = ( + nodePath.findLast( + (node) => node.type === "Route" && node.raw.type === "bricks" + ) as StoryboardNodeRoute + ).raw as RouteConfOfBricks; + if (typeof node.rawKey === "number") { + replaceSegues( + node.raw, + parent.rawContainer[parent.rawKey] as BrickEventHandler[], + node.rawKey, + routeParent + ); + } else { + replaceSegues( + node.raw, + parent.rawContainer, + parent.rawKey, + routeParent + ); + } } - } - } - - if (brick.slots) { - for (const slotConf of Object.values(brick.slots)) { - if (slotConf.type === "routes") { - initializeSeguesForRoutes(slotConf.routes); - } else { - initializeSeguesForBricks(slotConf.bricks, routeParent); - } - } - } else if (Array.isArray(brick.children)) { - initializeSeguesForBricks(brick.children, routeParent); + break; } - } + }); } function replaceSegues( @@ -106,17 +102,18 @@ function replaceSegues( typeof segueTarget === "string") && (segueConf = handler.args[1] as SegueConf) ) { - switch (segueConf.by) { + switch (segueConf.type) { case "drawer": { if (segueConf.route) { const { params, path } = segueConf.route; - const targetUrlExpr = path.replace(/:(\w+)/g, (_, key) => { - const param = params.find((param) => param.key === key); - return param - ? typeof param.value === "string" && isEvaluable(param.value) - ? `\${${param.value.replace(/^\s*<%[~=]?\s|\s%>\s*$/g, "")}}` - : String(param.value).replace(/[`\\]/g, "\\$&") - : `\${PATH.${key}}`; + const targetUrlExpr = path.replace(/:(\w+)/g, (_, k) => { + const hasParam = hasOwnProperty(params, k); + const paramValue = hasParam ? params[k] : undefined; + return hasParam + ? typeof paramValue === "string" && isEvaluable(paramValue) + ? `\${${paramValue.replace(/^\s*<%[~=]?\s|\s%>\s*$/g, "")}}` + : String(paramValue).replace(/[`\\]/g, "\\$&") + : `\${PATH.${k}}`; }); (handlers as BrickEventsMap)[key] = { action: "history.push", @@ -126,6 +123,7 @@ function replaceSegues( const drawerTarget = `#${drawerId}`; routeParent.bricks.push({ brick: "eo-drawer", + iid: drawerId, portal: true, properties: { id: drawerId, @@ -150,10 +148,7 @@ function replaceSegues( { brick: segueTarget, properties: Object.fromEntries( - params.map((param) => [ - param.key, - `<% PATH.${param.key} %>`, - ]) + Object.keys(params).map((k) => [k, `<% PATH.${k} %>`]) ), lifeCycle: { onMount: { @@ -185,32 +180,33 @@ function replaceSegues( target: modalTarget, method: "open", }; + + const replacements = new Map([ + ["_modal", modalTarget], + ["_scene", sceneTarget], + ]); + replaceSceneTarget(segueConf.modal?.events, replacements); + replaceSceneTarget(segueConf.scene?.events, replacements); + routeParent.bricks.push({ brick: "eo-modal", + iid: modalId, portal: true, properties: { - id: modalId, - modalTitle: "Create", closeWhenConfirm: false, + ...segueConf.modal?.properties, + id: modalId, }, - events: segueConf.eventsMapping - ? Object.fromEntries( - Object.entries(segueConf.eventsMapping).map(([from, to]) => [ - from, - { - target: sceneTarget, - method: to, - }, - ]) - ) - : undefined, + events: segueConf.modal?.events, children: [ { brick: segueTarget, + iid: sceneId, properties: { + ...segueConf.scene?.properties, id: sceneId, }, - events: segueConf.events, + events: segueConf.scene?.events, }, ], }); @@ -219,3 +215,22 @@ function replaceSegues( } } } + +function replaceSceneTarget( + events: BrickEventsMap | undefined, + replacements: Map +) { + const ast = parseEvents(events); + + traverse(ast, (node) => { + switch (node.type) { + case "EventHandler": + if (isCustomHandler(node.raw) && typeof node.raw.target === "string") { + const replacement = replacements.get(node.raw.target); + if (replacement !== undefined) { + node.raw.target = replacement; + } + } + } + }); +} diff --git a/packages/utils/src/storyboard/index.ts b/packages/utils/src/storyboard/index.ts index 5278e058b0..b4801c0293 100644 --- a/packages/utils/src/storyboard/index.ts +++ b/packages/utils/src/storyboard/index.ts @@ -8,3 +8,4 @@ export { /** @deprecated import it from "@next-core/utils/general" instead */ unwrapProvider, }; +export * from "./parser/index.js"; diff --git a/packages/utils/src/storyboard/parser/index.ts b/packages/utils/src/storyboard/parser/index.ts new file mode 100644 index 0000000000..fcacfbb20b --- /dev/null +++ b/packages/utils/src/storyboard/parser/index.ts @@ -0,0 +1,3 @@ +export { type ParseOptions, parseStoryboard, parseEvents } from "./parser.js"; +export * from "./interfaces.js"; +export * from "./traverse.js"; diff --git a/packages/utils/src/storyboard/parser/interfaces.ts b/packages/utils/src/storyboard/parser/interfaces.ts new file mode 100644 index 0000000000..c71b9c3055 --- /dev/null +++ b/packages/utils/src/storyboard/parser/interfaces.ts @@ -0,0 +1,271 @@ +import type { + BrickConf, + BrickEventHandler, + BrickEventHandlerCallback, + BrickEventsMap, + BrickLifeCycle, + ContextConf, + CustomTemplate, + CustomTemplateConstructor, + MessageConf, + ResolveConf, + RouteConf, + ScrollIntoViewConf, + SlotConf, + Storyboard, + UseSingleBrickConf, +} from "@next-core/types"; + +export type LegacyBrickMenuConf = unknown; +export type LegacyProviderConf = string | BrickConf; +export type MenuRawData = { + items?: MenuItemRawData[]; +}; +export type MenuItemRawData = { + children?: MenuItemRawData[]; +}; +export type LegacyBrickConf = BrickConf & { + context?: ContextConf[]; +}; +export type LegacyRouteConf = RouteConf & { + providers?: LegacyProviderConf[]; + defineResolves?: ResolveConf[]; +}; + +export type StoryboardNode = + | StoryboardNodeRoot + | StoryboardNodeRoute + | StoryboardNodeTemplate + | StoryboardNodeBrick + | StoryboardNodeSlot + | StoryboardNodeContext + | StoryboardNodeResolvable + | StoryboardNodeMenu + | StoryboardNodeLifeCycle + | StoryboardNodeEvent + | StoryboardNodeEventHandler + | StoryboardNodeConditionalEvent + | StoryboardNodeEventCallback + | StoryboardNodeCondition + | StoryboardNodeUseBrickEntry + | StoryboardNodeUseBackendEntry + | StoryboardNodeMetaMenu + | StoryboardNodeMetaMenuItem; + +export interface StoryboardNodeRoot { + type: "Root"; + raw: Storyboard; + routes: StoryboardNodeRoute[]; + templates: StoryboardNodeTemplate[]; + menus: StoryboardNodeMetaMenu[]; +} + +export interface StoryboardNodeTemplate { + type: "Template"; + raw: CustomTemplate | CustomTemplateConstructor; + bricks?: StoryboardNodeBrick[]; + context?: StoryboardNodeContext[]; +} + +export interface StoryboardNodeRoute { + type: "Route"; + raw: RouteConf; + context?: StoryboardNodeContext[]; + redirect?: StoryboardNodeResolvable; + menu?: StoryboardNodeMenu; + providers?: StoryboardNodeBrick[]; + defineResolves?: StoryboardNodeResolvable[]; + children: StoryboardNodeRoute[] | StoryboardNodeBrick[]; +} + +export type StoryboardNodeBrick = + | StoryboardNodeNormalBrick + | StoryboardNodeUseBrick; + +export interface StoryboardNodeBrickBase { + type: "Brick"; + raw: BrickConf | UseSingleBrickConf; + isUseBrick?: boolean; + if?: StoryboardNodeCondition; + events?: StoryboardNodeEvent[]; + lifeCycle?: StoryboardNodeLifeCycle[]; + useBrick?: StoryboardNodeUseBrickEntry[]; + useBackend?: StoryboardNodeUseBackendEntry[]; + context?: StoryboardNodeContext[]; + children: StoryboardNodeSlot[]; +} + +export interface StoryboardNodeNormalBrick extends StoryboardNodeBrickBase { + raw: BrickConf; + isUseBrick?: false; +} + +export interface StoryboardNodeUseBrick extends StoryboardNodeBrickBase { + raw: UseSingleBrickConf; + isUseBrick: true; +} + +export interface StoryboardNodeUseBrickEntry + extends StoryboardNodeUseEntryBase { + type: "UseBrickEntry"; + rawKey: "useBrick"; +} + +export interface StoryboardNodeUseBackendEntry + extends StoryboardNodeUseEntryBase { + type: "UseBackendEntry"; + rawKey: "useBackend"; +} + +export interface StoryboardNodeUseEntryBase { + rawContainer: Record; + children: StoryboardNodeBrick[]; +} + +export type StoryboardNodeCondition = + | StoryboardNodeLiteralCondition + | StoryboardNodeResolvableCondition; + +export interface StoryboardNodeLiteralCondition { + type: "LiteralCondition"; +} + +export interface StoryboardNodeResolvableCondition { + type: "ResolvableCondition"; + resolve: StoryboardNodeResolvable | undefined; +} + +export interface StoryboardNodeSlot { + type: "Slot"; + raw: SlotConf; + slot: string; + childrenType: "Route" | "Brick"; + children: StoryboardNodeRoute[] | StoryboardNodeBrick[]; +} + +export interface StoryboardNodeContext { + type: "Context"; + raw: ContextConf /* | CustomTemplateState */; + resolve?: StoryboardNodeResolvable; + onChange?: StoryboardNodeEventHandler[]; +} + +export interface StoryboardNodeResolvable { + type: "Resolvable"; + raw: ResolveConf; + isConditional?: boolean; +} + +export type StoryboardNodeMenu = + | StoryboardNodeFalseMenu + | StoryboardNodeStaticMenu + | StoryboardNodeBrickMenu + | StoryboardNodeResolvableMenu; + +export interface StoryboardNodeFalseMenu { + type: "FalseMenu"; +} + +export interface StoryboardNodeStaticMenu { + type: "StaticMenu"; +} + +export interface StoryboardNodeBrickMenu { + type: "BrickMenu"; + raw: LegacyBrickMenuConf; + brick?: StoryboardNodeBrick; +} + +export interface StoryboardNodeResolvableMenu { + type: "ResolvableMenu"; + resolve?: StoryboardNodeResolvable; +} + +export type StoryboardNodeLifeCycle = + | StoryboardNodeResolveLifeCycle + | StoryboardNodeSimpleLifeCycle + | StoryboardNodeConditionalLifeCycle + | StoryboardNodeUnknownLifeCycle; + +export interface StoryboardNodeResolveLifeCycle { + type: "ResolveLifeCycle"; + rawContainer: BrickLifeCycle; + rawKey: "useResolves"; + resolves: StoryboardNodeResolvable[] | undefined; +} + +export interface StoryboardNodeSimpleLifeCycle { + type: "SimpleLifeCycle"; + name: + | "onPageLoad" + | "onPageLeave" + | "onAnchorLoad" + | "onAnchorUnload" + | "onMessageClose" + | "onBeforePageLoad" + | "onBeforePageLeave" + | "onMount" + | "onUnmount" + | "onMediaChange"; + rawContainer: BrickLifeCycle; + rawKey: string; + handlers: StoryboardNodeEventHandler[]; +} + +export interface StoryboardNodeConditionalLifeCycle { + type: "ConditionalLifeCycle"; + name: "onMessage" | "onScrollIntoView"; + events: StoryboardNodeConditionalEvent[] | undefined; +} + +export interface StoryboardNodeUnknownLifeCycle { + type: "UnknownLifeCycle"; + rawContainer: BrickLifeCycle; + rawKey: string; +} + +export interface StoryboardNodeEvent { + type: "Event"; + rawContainer: BrickEventsMap; + rawKey: string; + handlers: StoryboardNodeEventHandler[] | undefined; +} + +export interface StoryboardNodeEventHandler { + type: "EventHandler"; + raw: BrickEventHandler; + /** + * `rawKey: undefined` means the event handler is not in an array. + * `rawKey: number` means the event handler is in an array. + */ + rawKey?: number; + callback: StoryboardNodeEventCallback[] | undefined; + then: StoryboardNodeEventHandler[] | undefined; + else: StoryboardNodeEventHandler[] | undefined; +} + +export interface StoryboardNodeConditionalEvent { + type: "ConditionalEvent"; + rawContainer: MessageConf | ScrollIntoViewConf; + rawKey: "handlers"; + handlers: StoryboardNodeEventHandler[] | undefined; +} + +export interface StoryboardNodeEventCallback { + type: "EventCallback"; + rawContainer: BrickEventHandlerCallback; + rawKey: string; + handlers: StoryboardNodeEventHandler[] | undefined; +} + +export interface StoryboardNodeMetaMenu { + type: "MetaMenu"; + raw: MenuRawData; + items?: StoryboardNodeMetaMenuItem[]; +} + +export interface StoryboardNodeMetaMenuItem { + type: "MetaMenuItem"; + raw: MenuItemRawData; + children?: StoryboardNodeMetaMenuItem[]; +} diff --git a/packages/utils/src/storyboard/parser/parser.spec.ts b/packages/utils/src/storyboard/parser/parser.spec.ts new file mode 100644 index 0000000000..5d7367364c --- /dev/null +++ b/packages/utils/src/storyboard/parser/parser.spec.ts @@ -0,0 +1,698 @@ +import type { + BrickConf, + BrickEventsMap, + CustomTemplate, + MenuConf, + RouteConfOfBricks, + Storyboard, +} from "@next-core/types"; +import { + parseBrick, + parseEvents, + parseMenu, + parseMetaMenus, + parseStoryboard, + parseTemplates, +} from "./parser.js"; +import type { MenuRawData } from "./interfaces.js"; + +describe("parser", () => { + it("should parse an empty storyboard", () => { + const storyboard = {} as unknown as Storyboard; + + const result = parseStoryboard(storyboard); + + expect(result).toEqual({ + type: "Root", + raw: storyboard, + routes: [], + templates: [], + menus: [], + }); + }); + + it("should parse storyboard with routes", () => { + const storyboard = { + routes: [ + { + type: "bricks", + path: "/home", + context: [], + bricks: [ + { + brick: "button", + lifeCycle: { + onPageLoad: { + action: "message.success", + args: ["Loaded"], + }, + // Legacy useResolves will be ignored by default + useResolves: [ + { + useProvider: "my-legacy-use-resolve", + }, + ], + onMessage: { + handlers: { + action: "console.log", + }, + }, + }, + }, + ], + providers: [ + "my-legacy-provider", + { + brick: "my-legacy-provider-2", + }, + ], + defineResolves: [ + { + useProvider: "my-legacy-define-resolve", + }, + ], + // Legacy redirect of non-redirect route will be ignored by default + redirect: { + useProvider: "my-legacy-redirect", + args: ["/any"], + }, + }, + { + redirect: { + useProvider: "my-redirect-provider", + }, + }, + { + type: "redirect", + redirect: "/home", + }, + { + type: "routes", + }, + ], + } as unknown as Storyboard; + const firstRoute = storyboard.routes[0] as RouteConfOfBricks; + const firstBrick = firstRoute.bricks[0]; + + const result = parseStoryboard(storyboard); + + expect(result).toEqual({ + menus: [], + raw: storyboard, + routes: [ + { + children: [ + { + children: [], + context: undefined, + events: undefined, + isUseBrick: undefined, + lifeCycle: [ + { + handlers: [ + { + callback: undefined, + else: [], + raw: { + action: "message.success", + args: ["Loaded"], + }, + rawKey: undefined, + then: [], + type: "EventHandler", + }, + ], + name: "onPageLoad", + rawContainer: firstBrick.lifeCycle, + rawKey: "onPageLoad", + type: "SimpleLifeCycle", + }, + { + rawContainer: firstBrick.lifeCycle, + rawKey: "useResolves", + type: "UnknownLifeCycle", + }, + { + events: [ + { + handlers: [ + { + callback: undefined, + else: [], + raw: { + action: "console.log", + }, + rawKey: undefined, + then: [], + type: "EventHandler", + }, + ], + rawContainer: { + handlers: { action: "console.log" }, + }, + rawKey: "handlers", + type: "ConditionalEvent", + }, + ], + name: "onMessage", + type: "ConditionalLifeCycle", + }, + ], + raw: firstBrick, + type: "Brick", + useBackend: [], + useBrick: [], + }, + ], + context: [], + defineResolves: undefined, + menu: undefined, + providers: undefined, + raw: firstRoute, + redirect: undefined, + type: "Route", + }, + { + children: [], + raw: storyboard.routes[1], + redirect: undefined, + type: "Route", + }, + { + children: [], + raw: storyboard.routes[2], + redirect: undefined, + type: "Route", + }, + { + children: [], + raw: storyboard.routes[3], + redirect: undefined, + type: "Route", + }, + ], + templates: [], + type: "Root", + }); + + const legacyResult = parseStoryboard(storyboard, { legacy: true }); + + expect(legacyResult).toEqual({ + menus: [], + raw: storyboard, + routes: [ + { + children: [ + { + children: [], + context: undefined, + events: undefined, + isUseBrick: undefined, + lifeCycle: [ + { + handlers: [ + { + callback: undefined, + else: [], + raw: { + action: "message.success", + args: ["Loaded"], + }, + rawKey: undefined, + then: [], + type: "EventHandler", + }, + ], + name: "onPageLoad", + rawContainer: firstBrick.lifeCycle, + rawKey: "onPageLoad", + type: "SimpleLifeCycle", + }, + { + rawContainer: firstBrick.lifeCycle, + rawKey: "useResolves", + resolves: [ + { + isConditional: true, + raw: { + useProvider: "my-legacy-use-resolve", + }, + type: "Resolvable", + }, + ], + type: "ResolveLifeCycle", + }, + expect.objectContaining({ + type: "ConditionalLifeCycle", + }), + ], + raw: firstBrick, + type: "Brick", + useBackend: [], + useBrick: [], + }, + ], + context: [], + defineResolves: [ + { + isConditional: undefined, + raw: { + useProvider: "my-legacy-define-resolve", + }, + type: "Resolvable", + }, + ], + providers: [ + expect.objectContaining({ + raw: { + brick: "my-legacy-provider", + }, + type: "Brick", + }), + expect.objectContaining({ + raw: { + brick: "my-legacy-provider-2", + }, + type: "Brick", + }), + ], + raw: firstRoute, + redirect: { + isConditional: undefined, + raw: { + useProvider: "my-legacy-redirect", + args: ["/any"], + }, + type: "Resolvable", + }, + type: "Route", + }, + { + children: [], + raw: storyboard.routes[1], + redirect: { + isConditional: undefined, + raw: { + useProvider: "my-redirect-provider", + }, + type: "Resolvable", + }, + type: "Route", + }, + { + children: [], + raw: storyboard.routes[2], + redirect: undefined, + type: "Route", + }, + { + children: [], + raw: storyboard.routes[3], + redirect: undefined, + type: "Route", + }, + ], + templates: [], + type: "Root", + }); + }); + + it("should parse useBrick and useBackend", () => { + const brick: BrickConf = { + brick: "my-brick", + if: { + useProvider: "my-condition", + }, + properties: { + title: "My Brick", + array: [1], + help: { + useBrick: { + brick: "my-inner-brick-1", + children: [ + { + brick: "my-inner-brick-2", + }, + ], + }, + }, + service: { + useBackend: { + provider: "my-backend-provider", + }, + }, + }, + slots: { + "": { + type: "routes", + routes: [], + }, + }, + }; + const result = parseBrick(brick); + expect(result).toEqual({ + children: [ + { + children: [], + childrenType: "Route", + raw: { + routes: [], + type: "routes", + }, + slot: "", + type: "Slot", + }, + ], + context: undefined, + events: undefined, + if: { + resolve: { + isConditional: undefined, + raw: { + useProvider: "my-condition", + }, + type: "Resolvable", + }, + type: "ResolvableCondition", + }, + isUseBrick: undefined, + lifeCycle: undefined, + raw: brick, + type: "Brick", + useBackend: [ + { + children: [ + expect.objectContaining({ + raw: { + brick: "my-backend-provider", + }, + type: "Brick", + }), + ], + rawContainer: { + useBackend: { + provider: "my-backend-provider", + }, + }, + rawKey: "useBackend", + type: "UseBackendEntry", + }, + ], + useBrick: [ + { + children: [ + { + children: [ + { + children: [ + { + children: [], + context: undefined, + events: undefined, + isUseBrick: true, + lifeCycle: undefined, + raw: { + brick: "my-inner-brick-2", + }, + type: "Brick", + useBackend: [], + useBrick: [], + }, + ], + childrenType: "Brick", + raw: { + bricks: [ + { + brick: "my-inner-brick-2", + }, + ], + type: "bricks", + }, + slot: "", + type: "Slot", + }, + ], + context: undefined, + events: undefined, + isUseBrick: true, + lifeCycle: undefined, + raw: (brick.properties!.help as { useBrick: unknown }).useBrick, + type: "Brick", + useBackend: [], + useBrick: [], + }, + ], + rawContainer: brick.properties!.help, + rawKey: "useBrick", + type: "UseBrickEntry", + }, + ], + }); + }); + + it("should parse events", () => { + const events: BrickEventsMap = { + click: { + target: "#my-brick", + method: "resolve", + callback: { + success: [ + { + action: "message.success", + }, + ], + }, + }, + }; + const result = parseEvents(events); + expect(result).toEqual([ + { + handlers: [ + { + callback: [ + { + handlers: [ + { + callback: undefined, + else: [], + raw: { + action: "message.success", + }, + rawKey: 0, + then: [], + type: "EventHandler", + }, + ], + rawContainer: { + success: [ + { + action: "message.success", + }, + ], + }, + rawKey: "success", + type: "EventCallback", + }, + ], + else: [], + raw: events.click, + rawKey: undefined, + then: [], + type: "EventHandler", + }, + ], + rawContainer: events, + rawKey: "click", + type: "Event", + }, + ]); + }); + + it("should parse custom templates", () => { + const customTemplates: CustomTemplate[] = [ + { + name: "tpl-test", + bricks: [{ if: true, brick: "div" }], + state: [ + { + name: "test", + value: "any", + }, + { + name: "resolveValue", + resolve: { + useProvider: "my-resolve", + }, + onChange: { + action: "message.success", + }, + }, + ], + }, + ]; + + const result = parseTemplates(customTemplates); + + expect(result).toEqual([ + { + bricks: [ + { + children: [], + context: undefined, + events: undefined, + if: { + type: "LiteralCondition", + }, + isUseBrick: undefined, + lifeCycle: undefined, + raw: { + brick: "div", + if: true, + }, + type: "Brick", + useBackend: [], + useBrick: [], + }, + ], + context: [ + { + onChange: [], + raw: { + name: "test", + value: "any", + }, + resolve: undefined, + type: "Context", + }, + { + onChange: [ + { + callback: undefined, + else: [], + raw: { + action: "message.success", + }, + rawKey: undefined, + then: [], + type: "EventHandler", + }, + ], + raw: customTemplates[0].state![1], + resolve: { + isConditional: undefined, + raw: { + useProvider: "my-resolve", + }, + type: "Resolvable", + }, + type: "Context", + }, + ], + raw: customTemplates[0], + type: "Template", + }, + ]); + }); + + it("should parse menu", () => { + expect(parseMenu(false)).toEqual({ type: "FalseMenu" }); + expect(parseMenu(undefined)).toEqual(undefined); + expect(parseMenu({ menuId: "my-menu" })).toEqual({ type: "StaticMenu" }); + + expect( + parseMenu({ + type: "resolve", + resolve: { useProvider: "my-menu-resolve" }, + }) + ).toEqual({ + resolve: { + isConditional: undefined, + raw: { + useProvider: "my-menu-resolve", + }, + type: "Resolvable", + }, + type: "ResolvableMenu", + }); + + // Legacy brick menu is ignored by default. + expect( + parseMenu({ + type: "brick", + brick: "my-legacy-menu", + } as unknown as MenuConf) + ).toEqual(undefined); + // Unless `options.legacy` is set to true + expect( + parseMenu( + { type: "brick", brick: "my-legacy-menu" } as unknown as MenuConf, + { legacy: true } + ) + ).toEqual({ + type: "BrickMenu", + brick: expect.objectContaining({ + raw: { + brick: "my-legacy-menu", + type: "brick", + }, + type: "Brick", + }), + raw: { + brick: "my-legacy-menu", + type: "brick", + }, + }); + }); + + it("should parse meta.menus", () => { + const menus = [ + { + title: "A", + items: [ + { + title: "A-1", + children: [ + { + title: "A-1-1", + children: [], + }, + ], + }, + ], + }, + { title: "B" }, + ] as unknown as MenuRawData[]; + + const result = parseMetaMenus(menus); + + expect(result).toEqual([ + { + items: [ + { + children: [ + { + children: [], + raw: { + children: [], + title: "A-1-1", + }, + type: "MetaMenuItem", + }, + ], + raw: menus[0].items![0], + type: "MetaMenuItem", + }, + ], + raw: menus[0], + type: "MetaMenu", + }, + { + items: [], + raw: { + title: "B", + }, + type: "MetaMenu", + }, + ]); + }); +}); diff --git a/packages/utils/src/storyboard/parser/parser.ts b/packages/utils/src/storyboard/parser/parser.ts new file mode 100644 index 0000000000..0ecf34486c --- /dev/null +++ b/packages/utils/src/storyboard/parser/parser.ts @@ -0,0 +1,504 @@ +import type { + BrickConf, + BrickEventHandler, + BrickEventHandlerCallback, + BrickEventsMap, + BrickLifeCycle, + ConditionalEventHandler, + ContextConf, + CustomTemplate, + CustomTemplateConstructor, + MenuConf, + MessageConf, + ResolveConf, + ResolveMenuConf, + RouteConf, + RouteConfOfBricks, + RouteConfOfRedirect, + ScrollIntoViewConf, + SlotConfOfBricks, + SlotsConf, + Storyboard, + UseProviderEventHandler, + UseSingleBrickConf, +} from "@next-core/types"; +import { hasOwnProperty, isObject } from "@next-core/utils/general"; +import type { + LegacyProviderConf, + LegacyRouteConf, + MenuItemRawData, + MenuRawData, + StoryboardNodeBrick, + StoryboardNodeCondition, + StoryboardNodeConditionalEvent, + StoryboardNodeContext, + StoryboardNodeEvent, + StoryboardNodeEventCallback, + StoryboardNodeEventHandler, + StoryboardNodeLifeCycle, + StoryboardNodeMenu, + StoryboardNodeMetaMenu, + StoryboardNodeMetaMenuItem, + StoryboardNodeResolvable, + StoryboardNodeRoot, + StoryboardNodeRoute, + StoryboardNodeSlot, + StoryboardNodeTemplate, + StoryboardNodeUseBackendEntry, + StoryboardNodeUseBrickEntry, +} from "./interfaces.js"; + +export interface ParseOptions { + legacy?: boolean; + isUseBrick?: boolean; +} + +/** Parse storyboard as AST. */ +export function parseStoryboard( + storyboard: Storyboard, + options?: ParseOptions +): StoryboardNodeRoot { + return { + type: "Root", + raw: storyboard, + routes: parseRoutes(storyboard.routes, options), + templates: parseTemplates(storyboard.meta?.customTemplates, options), + menus: parseMetaMenus(storyboard.meta?.menus), + }; +} + +/** Parse storyboard routes as AST. */ +export function parseRoutes( + routes: RouteConf[], + options?: ParseOptions +): StoryboardNodeRoute[] { + if (Array.isArray(routes)) { + return routes.map((route) => ({ + type: "Route", + raw: route, + context: parseContext(route.context), + redirect: + options?.legacy || route.type === "redirect" + ? parseResolvable( + (route as RouteConfOfRedirect).redirect as ResolveConf + ) + : undefined, + menu: parseMenu(route.menu, options), + providers: options?.legacy + ? parseRouteProviders((route as LegacyRouteConf).providers) + : undefined, + defineResolves: + options?.legacy && + Array.isArray((route as LegacyRouteConf).defineResolves) + ? ((route as LegacyRouteConf) + .defineResolves!.map((item) => parseResolvable(item)) + .filter(Boolean) as StoryboardNodeResolvable[]) + : undefined, + children: + route.type === "routes" + ? parseRoutes(route.routes, options) + : options?.legacy || route.type !== "redirect" + ? parseBricks((route as RouteConfOfBricks).bricks, options) + : [], + })); + } + return []; +} + +/** Parse storyboard templates as AST. */ +export function parseTemplates( + templates: (CustomTemplate | CustomTemplateConstructor)[] | undefined, + options?: ParseOptions +): StoryboardNodeTemplate[] { + if (Array.isArray(templates)) { + return templates.map((tpl) => + parseTemplate(tpl, options) + ); + } + return []; +} + +/** Parse a storyboard template as AST. */ +export function parseTemplate( + tpl: CustomTemplate | CustomTemplateConstructor, + options?: ParseOptions +): StoryboardNodeTemplate { + return { + type: "Template", + raw: tpl, + bricks: parseBricks(tpl.bricks, options), + context: parseContext(tpl.state), + }; +} + +export function parseBricks( + bricks: BrickConf[] | UseSingleBrickConf[], + options?: ParseOptions +): StoryboardNodeBrick[] { + if (Array.isArray(bricks)) { + return bricks.map((brick) => parseBrick(brick, options)); + } + return []; +} + +/** Parse a storyboard brick as AST. */ +export function parseBrick( + brick: BrickConf | UseSingleBrickConf, + options?: ParseOptions +): StoryboardNodeBrick { + return { + type: "Brick", + raw: brick, + isUseBrick: options?.isUseBrick, + if: parseCondition(brick), + events: parseEvents(brick.events), + lifeCycle: parseLifeCycles(brick.lifeCycle, options), + ...parseBrickProperties(brick.properties, options), + context: parseContext( + (brick as BrickConf & { context?: ContextConf[] }).context + ), + children: parseSlots( + childrenToSlots( + (brick as { children?: BrickConf[] }).children, + brick.slots as SlotsConf + ), + options + ), + } as StoryboardNodeBrick; +} + +function parseCondition( + conditionContainer: Pick +): StoryboardNodeCondition | undefined { + if (hasOwnProperty(conditionContainer, "if")) { + const condition = conditionContainer.if; + if (isObject(condition)) { + return { + type: "ResolvableCondition", + resolve: parseResolvable(condition as ResolveConf), + }; + } + return { + type: "LiteralCondition", + }; + } +} + +function parseBrickProperties( + props: unknown, + options?: ParseOptions +): { + useBrick?: StoryboardNodeUseBrickEntry[]; + useBackend?: StoryboardNodeUseBackendEntry[]; +} { + const useBrick: StoryboardNodeUseBrickEntry[] = []; + const useBackend: StoryboardNodeUseBackendEntry[] = []; + + function walkBrickProperties(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) { + walkBrickProperties(item); + } + } else if (isObject(value)) { + if (value.useBrick || value.useBackend) { + if (value.useBrick) { + useBrick.push({ + type: "UseBrickEntry", + rawContainer: value, + rawKey: "useBrick", + children: parseBricks( + ([] as UseSingleBrickConf[]).concat( + value.useBrick as UseSingleBrickConf + ), + { + ...options, + isUseBrick: true, + } + ), + }); + } + const provider = (value.useBackend as { provider?: string } | undefined) + ?.provider; + if (typeof provider === "string") { + useBackend.push({ + type: "UseBackendEntry", + rawContainer: value, + rawKey: "useBackend", + children: [parseBrick({ brick: provider })], + }); + } + } else { + for (const item of Object.values(value)) { + walkBrickProperties(item); + } + } + } + } + + walkBrickProperties(props); + + return { useBrick, useBackend }; +} + +export function parseLifeCycles( + lifeCycle: BrickLifeCycle | undefined, + options?: ParseOptions +): StoryboardNodeLifeCycle[] | undefined { + if (isObject(lifeCycle)) { + return Object.entries( + lifeCycle as BrickLifeCycle + ).map(([name, conf]) => { + switch (name) { + case "onPageLoad": + case "onPageLeave": + case "onAnchorLoad": + case "onAnchorUnload": + case "onMessageClose": + case "onBeforePageLoad": + case "onBeforePageLeave": + case "onMount": + case "onUnmount": + case "onMediaChange": + return { + type: "SimpleLifeCycle", + name, + rawContainer: lifeCycle, + rawKey: name, + handlers: parseEventHandlers(conf), + }; + case "onMessage": + case "onScrollIntoView": + return { + type: "ConditionalLifeCycle", + name, + events: ([] as (MessageConf | ScrollIntoViewConf)[]) + .concat(conf) + .filter(Boolean) + .map((item) => ({ + type: "ConditionalEvent", + rawContainer: item, + rawKey: "handlers", + handlers: parseEventHandlers(item.handlers), + })), + }; + default: + if (name === "useResolves" && options?.legacy) { + return { + type: "ResolveLifeCycle", + rawContainer: lifeCycle, + rawKey: name, + resolves: (conf as ResolveConf[] | undefined)?.map( + (item) => parseResolvable(item, true)! + ), + }; + } + return { + type: "UnknownLifeCycle", + rawContainer: lifeCycle, + rawKey: name, + }; + } + }); + } +} + +function childrenToSlots( + children: BrickConf[] | undefined, + originalSlots: SlotsConf | undefined +): SlotsConf | undefined { + let newSlots = originalSlots; + // istanbul ignore next + if ( + process.env.NODE_ENV === "development" && + children && + !Array.isArray(children) + ) { + // eslint-disable-next-line no-console + console.warn( + "Specified brick children but not array:", + `<${typeof children}>`, + children + ); + } + if (Array.isArray(children) && !newSlots) { + newSlots = {}; + for (const child of children) { + const slot = (child as { slot?: string }).slot ?? ""; + if (!Object.prototype.hasOwnProperty.call(newSlots, slot)) { + newSlots[slot] = { + type: "bricks", + bricks: [], + }; + } + (newSlots[slot] as SlotConfOfBricks).bricks.push(child); + } + } + return newSlots; +} + +function parseSlots( + slots: SlotsConf | undefined, + options?: ParseOptions +): StoryboardNodeSlot[] { + if (isObject(slots)) { + return Object.entries(slots).map(([slot, conf]) => ({ + type: "Slot", + raw: conf, + slot, + childrenType: conf.type === "routes" ? "Route" : "Brick", + children: + conf.type === "routes" + ? parseRoutes(conf.routes, options) + : parseBricks(conf.bricks, options), + })); + } + return []; +} + +export function parseEvents( + events: BrickEventsMap | undefined +): StoryboardNodeEvent[] | undefined { + if (isObject(events)) { + return Object.entries(events).map( + ([eventType, handlers]) => ({ + type: "Event", + rawContainer: events, + rawKey: eventType, + handlers: parseEventHandlers(handlers), + }) + ); + } +} + +function parseContext( + contexts: ContextConf /* | CustomTemplateState */[] | undefined +): StoryboardNodeContext[] | undefined { + if (Array.isArray(contexts)) { + return contexts.map((context) => ({ + type: "Context", + raw: context, + resolve: parseResolvable(context.resolve), + onChange: parseEventHandlers(context.onChange), + })); + } +} + +export function parseMenu( + menu: MenuConf | undefined, + options?: ParseOptions +): StoryboardNodeMenu | undefined { + if (menu === false) { + return { type: "FalseMenu" }; + } + if (!menu) { + return; + } + switch (menu.type as "brick" | "resolve" | "static") { + case "brick": + return options?.legacy + ? { + type: "BrickMenu", + raw: menu, + brick: parseBrick(menu as unknown as BrickConf), + } + : undefined; + case "resolve": + return { + type: "ResolvableMenu", + resolve: parseResolvable((menu as ResolveMenuConf).resolve), + }; + default: + return { + type: "StaticMenu", + }; + } +} + +function parseResolvable( + resolve: ResolveConf | undefined, + isConditional?: boolean +): StoryboardNodeResolvable | undefined { + if (isObject(resolve)) { + return { + type: "Resolvable", + raw: resolve, + isConditional, + }; + } +} + +function parseEventHandlers( + handlers: BrickEventHandler | BrickEventHandler[] | undefined +): StoryboardNodeEventHandler[] { + return ([] as BrickEventHandler[]) + .concat(handlers ?? []) + .map((handler, index) => ({ + type: "EventHandler", + callback: parseEventCallback( + (handler as UseProviderEventHandler).callback + ), + then: parseEventHandlers((handler as ConditionalEventHandler).then), + else: parseEventHandlers((handler as ConditionalEventHandler).else), + raw: handler, + rawKey: Array.isArray(handlers) ? index : undefined, + })); +} + +function parseEventCallback( + callback: BrickEventHandlerCallback | undefined +): StoryboardNodeEventCallback[] | undefined { + if (isObject(callback)) { + return Object.entries(callback).map( + ([callbackType, handlers]) => ({ + type: "EventCallback", + rawContainer: callback as BrickEventsMap, + rawKey: callbackType, + handlers: parseEventHandlers(handlers as BrickEventHandler), + }) + ); + } +} + +function parseRouteProviders( + providers: LegacyProviderConf[] | undefined +): StoryboardNodeBrick[] | undefined { + if (Array.isArray(providers)) { + return providers.map((provider) => + parseBrick(typeof provider === "string" ? { brick: provider } : provider) + ); + } +} + +export function parseMetaMenus( + menus: MenuRawData[] | undefined +): StoryboardNodeMetaMenu[] { + if (Array.isArray(menus)) { + return menus.map(parseMetaMenu); + } + return []; +} + +function parseMetaMenu(menu: MenuRawData): StoryboardNodeMetaMenu { + return { + type: "MetaMenu", + raw: menu, + items: parseMetaItems(menu.items), + }; +} + +function parseMetaItems( + menuItems: MenuItemRawData[] | undefined +): StoryboardNodeMetaMenuItem[] { + if (Array.isArray(menuItems)) { + return menuItems.map(parseMetaItem); + } + return []; +} + +function parseMetaItem(menuItem: MenuItemRawData): StoryboardNodeMetaMenuItem { + return { + type: "MetaMenuItem", + raw: menuItem, + children: parseMetaItems(menuItem.children), + }; +} diff --git a/packages/utils/src/storyboard/parser/traverse.spec.ts b/packages/utils/src/storyboard/parser/traverse.spec.ts new file mode 100644 index 0000000000..18b2243810 --- /dev/null +++ b/packages/utils/src/storyboard/parser/traverse.spec.ts @@ -0,0 +1,184 @@ +import { + traverse, + traverseStoryboard, + type TraverseCallback, +} from "./traverse.js"; +import type { + StoryboardNode, + StoryboardNodeMetaMenu, + StoryboardNodeRoot, + StoryboardNodeRoute, +} from "./interfaces.js"; + +describe("traverse", () => { + const mockCallback: TraverseCallback = jest.fn(); + + it("should handle undefined node", () => { + traverse(undefined, mockCallback); + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it("should call callback with node and path", () => { + const node = { + type: "Root", + routes: [ + { + type: "Route", + }, + ], + } as Partial as StoryboardNodeRoot; + traverse(node, mockCallback); + expect(mockCallback).toHaveBeenNthCalledWith(1, node, []); + expect(mockCallback).toHaveBeenNthCalledWith(2, node.routes[0], [node]); + }); + + it("should call callback with node and path for traverseStoryboard", () => { + const node = { + type: "Root", + routes: [ + { + type: "Route", + }, + ], + } as Partial as StoryboardNodeRoot; + traverseStoryboard(node, mockCallback); + expect(mockCallback).toHaveBeenNthCalledWith(1, node, []); + expect(mockCallback).toHaveBeenNthCalledWith(2, node.routes[0], [node]); + }); + + it("should traverse Route node", () => { + const node = { + type: "Route", + context: [ + { + type: "Context", + raw: undefined!, + resolve: { + type: "Resolvable", + raw: undefined!, + }, + }, + ], + } as Partial as StoryboardNodeRoute; + const contextNode = node.context![0]; + traverse([node], mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(3); + expect(mockCallback).toHaveBeenNthCalledWith(1, node, []); + expect(mockCallback).toHaveBeenNthCalledWith(2, contextNode, [node]); + expect(mockCallback).toHaveBeenNthCalledWith(3, contextNode.resolve, [ + node, + contextNode, + ]); + }); + + it("should traverse Template node", () => { + const node = { + type: "Template", + bricks: [], + context: [], + } as Partial as StoryboardNode; + traverse(node, mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it("should traverse Brick node", () => { + const node = { + type: "Brick", + } as Partial as StoryboardNode; + traverse(node, mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it("should traverse Slot node", () => { + const node = { + type: "Slot", + children: [], + } as Partial as StoryboardNode; + traverse(node, mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it("should traverse Context node", () => { + const node = { + type: "Context", + resolve: undefined, + onChange: [], + } as Partial as StoryboardNode; + traverse(node, mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it("should traverse ResolvableCondition node", () => { + const node: StoryboardNode = { + type: "ResolvableCondition", + resolve: undefined, + }; + traverse(node, mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it("should traverse ResolveLifeCycle node", () => { + const node = { + type: "ResolveLifeCycle", + resolves: [], + } as Partial as StoryboardNode; + traverse(node, mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it("should traverse Event node", () => { + const node: StoryboardNode = { type: "Event", handlers: [] }; + traverse(node, mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it("should traverse EventHandler node", () => { + const node: StoryboardNode = { + type: "EventHandler", + callback: [], + then: [], + else: [], + }; + traverse(node, mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it("should traverse ConditionalLifeCycle node", () => { + const node = { + type: "ConditionalLifeCycle", + events: [], + name: "onMessage", + } as StoryboardNode; + traverse(node, mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it("should traverse BrickMenu node", () => { + const node = { type: "BrickMenu", brick: undefined } as StoryboardNode; + traverse(node, mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it("should traverse MetaMenu node", () => { + const node = { + type: "MetaMenu", + items: [ + { + type: "MetaMenuItem", + children: [], + raw: undefined!, + }, + ], + } as Partial as StoryboardNodeMetaMenu; + traverse(node, mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(2); + expect(mockCallback).toHaveBeenNthCalledWith(1, node, []); + expect(mockCallback).toHaveBeenNthCalledWith(2, node.items![0], [node]); + }); + + it("should handle unknown node type", () => { + const node: StoryboardNode = { type: "UnknownType" as any }; + traverse(node, mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/utils/src/storyboard/parser/traverse.ts b/packages/utils/src/storyboard/parser/traverse.ts new file mode 100644 index 0000000000..147ed3409e --- /dev/null +++ b/packages/utils/src/storyboard/parser/traverse.ts @@ -0,0 +1,131 @@ +import type { StoryboardNode, StoryboardNodeRoot } from "./interfaces.js"; + +export type TraverseCallback = ( + node: StoryboardNode, + path: StoryboardNode[] +) => void; + +/** Traverse a storyboard AST. */ +export function traverseStoryboard( + ast: StoryboardNodeRoot, + callback: TraverseCallback +): void { + traverseNode(ast, callback, []); +} + +/** Traverse any node(s) in storyboard AST. */ +export function traverse( + nodeOrNodes: StoryboardNode | StoryboardNode[] | undefined, + callback: TraverseCallback +): void { + if (Array.isArray(nodeOrNodes)) { + traverseNodes(nodeOrNodes, callback, []); + } else { + traverseNode(nodeOrNodes, callback, []); + } +} + +function traverseNodes( + nodes: StoryboardNode[] | undefined, + callback: TraverseCallback, + path: StoryboardNode[] +): void { + if (!nodes) { + return; + } + for (const node of nodes) { + traverseNode(node, callback, path); + } +} + +function traverseNode( + node: StoryboardNode | undefined, + callback: TraverseCallback, + path: StoryboardNode[] +): void { + if (!node) { + return; + } + callback(node, path); + const childPath = path.concat(node); + switch (node.type) { + case "Root": + traverseNodes(node.routes, callback, childPath); + traverseNodes(node.templates, callback, childPath); + traverseNodes(node.menus, callback, childPath); + break; + case "Route": + traverseNodes(node.context, callback, childPath); + traverseNode(node.redirect, callback, childPath); + traverseNode(node.menu, callback, childPath); + traverseNodes(node.providers, callback, childPath); + traverseNodes(node.defineResolves, callback, childPath); + traverseNodes(node.children, callback, childPath); + break; + case "Template": + traverseNodes(node.bricks, callback, childPath); + traverseNodes(node.context, callback, childPath); + break; + case "Brick": + traverseNode(node.if, callback, childPath); + traverseNodes(node.events, callback, childPath); + traverseNodes(node.lifeCycle, callback, childPath); + traverseNodes(node.useBrick, callback, childPath); + traverseNodes(node.useBackend, callback, childPath); + traverseNodes(node.context, callback, childPath); + traverseNodes(node.children, callback, childPath); + break; + case "Slot": + case "UseBrickEntry": + case "UseBackendEntry": + traverseNodes(node.children, callback, childPath); + break; + case "Context": + traverseNode(node.resolve, callback, childPath); + traverseNodes(node.onChange, callback, childPath); + break; + case "ResolvableCondition": + case "ResolvableMenu": + traverseNode(node.resolve, callback, childPath); + break; + case "ResolveLifeCycle": + traverseNodes(node.resolves, callback, childPath); + break; + case "Event": + case "EventCallback": + case "SimpleLifeCycle": + case "ConditionalEvent": + traverseNodes(node.handlers, callback, childPath); + break; + case "EventHandler": + traverseNodes(node.callback, callback, childPath); + traverseNodes(node.then, callback, childPath); + traverseNodes(node.else, callback, childPath); + break; + case "ConditionalLifeCycle": + traverseNodes(node.events, callback, childPath); + break; + case "BrickMenu": + traverseNode(node.brick, callback, childPath); + break; + case "MetaMenu": + traverseNodes(node.items, callback, childPath); + break; + case "MetaMenuItem": + traverseNodes(node.children, callback, childPath); + break; + case "Resolvable": + case "FalseMenu": + case "StaticMenu": + case "UnknownLifeCycle": + case "LiteralCondition": + break; + default: + // istanbul ignore if + if (process.env.NODE_ENV === "development") { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + throw new Error(`Unhandled storyboard node type: ${node.type}`); + } + } +}