packages/plugin-pha/src/manifestHelpers.ts (383 lines of code) (raw):

/* eslint-disable camelcase */ import * as path from 'path'; import * as fs from 'fs'; import humps from 'humps'; import consola from 'consola'; import cloneDeep from 'lodash.clonedeep'; import { matchRoutes } from '@remix-run/router'; import * as htmlparser2 from 'htmlparser2'; import { decamelizeKeys, camelizeKeys, validPageConfigKeys, pageDefaultValueKeys } from './constants.js'; import type { Page, PageHeader, PageConfig, Manifest, PHAManifest, Frame } from './types'; const { decamelize } = humps; interface TransformOptions { isRoot?: boolean; parentKey?: string; } export interface ParseOptions { urlPrefix: string; publicPath: string; routeManifest: string; excuteServerEntry: () => Promise<any>; routesConfig?: Record<string, any>; template?: boolean; preload?: boolean; urlSuffix?: string; ssr?: boolean; dataloaderConfig?: object | Function | Array<object | Function>; } interface TabConfig { name: string; url: string; html?: string; } type ResourcePrefetchConfig = Array<{ src: string; mimeType?: string; headers?: string; queryParams?: string; }>; interface InternalPageConfig { path?: string; document?: string; resourcePrefetch?: ResourcePrefetchConfig; } type MixedPage = InternalPageConfig & PageConfig; export function transformManifestKeys(manifest: Manifest, options?: TransformOptions): PHAManifest { const { parentKey, isRoot } = options || {}; const data = {}; for (let key in manifest) { // Filter not need transform key. if (isRoot && !decamelizeKeys.includes(key)) { continue; } const value = manifest[key]; // Compatible with pageHeader. if (key === 'pageHeader') { key = 'tabHeader'; } let transformKey = key; if (!camelizeKeys.includes(key)) { transformKey = decamelize(key); } if (typeof value === 'string' || typeof value === 'number') { data[transformKey] = value; } else if (Array.isArray(value)) { data[transformKey] = value.map((item) => { if (parentKey === 'tabBar' && item.text) { item.name = item.text; delete item.text; } if (typeof item === 'object') { if (key === 'dataPrefetch') { // Hack: No header will crash in Android if (!item.header) { item.header = {}; } // No prefetchKey will crash in Android TaoBao 9.26.0. if (!item.prefetchKey) { item.prefetchKey = 'mtop'; } } return transformManifestKeys(item, { isRoot: false, parentKey: key }); } return item; }); } else if (key === 'pullRefresh') { if (value && value.reload) { // Need reload. data['pull_refresh'] = true; } else { data['enable_pull_refresh'] = true; } } else if (key === 'requestHeaders') { // Keys of requestHeaders should not be transformed. data[transformKey] = value; } else if (typeof value === 'object' && !(parentKey === 'dataPrefetch' && (key === 'header' || key === 'data'))) { data[transformKey] = transformManifestKeys(value, { isRoot: false, parentKey: key }); } else { data[transformKey] = value; } } return data; } function getPageUrl(routeId: string, options: ParseOptions) { const { urlPrefix = '', urlSuffix = '' } = options; const splitCharacter = urlPrefix.endsWith('/') ? '' : '/'; return `${urlPrefix}${splitCharacter}${routeId}${urlSuffix}`; } function getRouteManifest(routeManifest: string) { try { const routes = fs.readFileSync(routeManifest, 'utf-8'); return JSON.parse(routes); } catch (e) { console.warn(`[plugin-pha warn] ${JSON.stringify(e)}`); return []; } } async function getPageConfig( routeId: string, routeManifest: string, routesConfig: Record<string, any>, ): Promise<MixedPage> { const routes = getRouteManifest(routeManifest); const matches = matchRoutes(routes, routeId.startsWith('/') ? routeId : `/${routeId}`); let routeConfig: MixedPage = {}; if (matches) { // Merge route config when return muitiple route. routeConfig = matches.reduce((prev, curr) => { const { id } = curr.route; return { ...prev, ...(routesConfig![id]?.() as MixedPage || {}), }; }, {}); } const filteredConfig = {}; Object.keys(routeConfig).forEach((key) => { if (validPageConfigKeys.includes(key)) { filteredConfig[key] = routeConfig[key]; } }); return filteredConfig; } async function renderPageDocument(routeId: string, excuteServerEntry: ParseOptions['excuteServerEntry']): Promise<string> { const serverContext = { req: { url: `/${routeId}`, }, }; const serverModule = await excuteServerEntry(); if (serverModule) { const { value } = await serverModule.renderToHTML(serverContext, { documentOnly: true, serverOnlyBasename: '/', renderMode: 'SSG', }); return value; } } async function getPageManifest(page: string | Page, options: ParseOptions): Promise<MixedPage> { const { template, preload, excuteServerEntry, routesConfig, routeManifest } = 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, routeManifest, routesConfig); const { queryParams = '', ...rest } = pageConfig; const pageManifest = { key: page, ...rest, }; if (template && !Array.isArray(pageConfig.frames)) { pageManifest.document = await renderPageDocument(page, excuteServerEntry); } if (preload) { let scripts = []; let stylesheets = []; function getPreload(dom) { if ( dom.name === 'script' && dom.attribs && dom.attribs.src ) { scripts.push({ src: dom.attribs.src, }); } else if ( dom.name === 'link' && dom.attribs && dom.attribs.href && dom.attribs.rel === 'stylesheet' ) { stylesheets.push({ src: dom.attribs.href, }); } if (dom.children) { dom.children.forEach(getPreload); } } getPreload(htmlparser2.parseDocument(pageManifest.document)); pageManifest.resourcePrefetch = [...scripts, ...stylesheets]; } // Always need path for page item. pageManifest.path = `${getPageUrl(page, options)}${queryParams ? `?${queryParams}` : ''}`; return pageManifest; } else if (page.url) { // Url has the highest priority to overwrite config path. const { url, ...rest } = page; return { ...rest, path: url, }; } // Return page config while it may config as pha manifest standard. return page; } const PAGE_SOURCE_REGEX = /^\.?\/?pages\//; function validateSource(source: string, key: string): boolean { if (!source.match(PAGE_SOURCE_REGEX)) { throw new Error(`${key} source must be written in pages folder`); } return true; } function parseRouteId(id: string): string { return id.replace(PAGE_SOURCE_REGEX, ''); } async function getTabConfig(tabManifest: Manifest['tabBar'] | PageHeader, generateDocument: boolean, options: ParseOptions): Promise<TabConfig> { const tabConfig: TabConfig = { name: '', url: '', }; const tabRouteId = parseRouteId(tabManifest!.source); if (options.template && generateDocument && options.excuteServerEntry) { tabConfig.html = await renderPageDocument(tabRouteId, options.excuteServerEntry); } // TODO: iOS issue // TODO: should remove it in PHA 2.x // PHA 1.x should inject `url` to be a base url to load assets tabConfig.url = getPageUrl(tabRouteId, options); // TODO: Android issue // TODO: should remove it in PHA 2.x // same as iOS issue try { tabConfig.name = new URL(tabConfig.url).origin; } catch (e) { // HACK: build type of Weex will inject an invalid URL, // which will throw Error when stringify using `new URL()` // invalid URL: {{xxx}}/path // {{xxx}} will replace by server [tabConfig.name] = tabConfig.url.split('/'); } return tabConfig; } export function getAppWorkerUrl(manifest: Manifest, workerDir: string): string { let appWorkerPath: string; const defaultAppWorker = path.join(workerDir, 'app-worker.ts'); if (manifest?.appWorker?.url) { const appWorkUrl = path.join(workerDir, manifest.appWorker.url); if (!manifest?.appWorker?.url.startsWith('http')) { if (fs.existsSync(appWorkUrl)) { appWorkerPath = appWorkUrl; } else { consola.error(`PHA app worker url: ${manifest.appWorker.url} is not exists`); } } } else if (fs.existsSync(defaultAppWorker)) { appWorkerPath = defaultAppWorker; } return appWorkerPath; } export function rewriteAppWorker(manifest: Manifest): Manifest { let appWorker: Manifest['appWorker'] = {}; if (manifest.appWorker) { appWorker = { ...manifest.appWorker, url: 'app-worker.js', }; } else { appWorker = { url: 'app-worker.js', }; } return { ...manifest, appWorker, }; } export function getRouteIdByPage(routeManifest: string, page: Page) { const routes = getRouteManifest(routeManifest); const routeId = typeof page === 'string' ? page : page?.name; const locationArg = routeId?.startsWith('/') ? routeId : `/${routeId}`; const matches = matchRoutes(routes, locationArg); return (matches || []).map((match) => { return match?.route?.id; }); } export async function parseManifest(manifest: Manifest, options: ParseOptions): Promise<PHAManifest> { const { publicPath, dataloaderConfig, routeManifest, } = options; const { appWorker, tabBar, routes } = manifest; if (appWorker?.url && !appWorker.url.startsWith('http')) { appWorker.url = `${publicPath}${appWorker.url}`; } if (tabBar?.source && validateSource(tabBar.source, 'tabBar')) { if (!tabBar.url) { manifest.tabBar = { ...tabBar, ...(await getTabConfig(tabBar, false, options)), }; } // Remove tab_bar.source because pha manifest do not recognize it. delete manifest.tabBar.source; } // items is `undefined` will crash in PHA and it is not supported to config list. if (tabBar && !tabBar.items) { tabBar.items = []; } if (routes && routes.length > 0) { manifest.pages = await Promise.all(routes.map(async (page) => { const pageIds = getRouteIdByPage(routeManifest, page); const pageManifest = await getPageManifest(page, options); // The manifest configuration is the default value for the page configuration. pageDefaultValueKeys.forEach(key => { if (!(key in pageManifest) && (key in manifest)) { pageManifest[key] = manifest[key]; } }); // Set static dataloader to data_prefetch of page. pageIds.forEach((pageId) => { if (typeof page === 'string' && dataloaderConfig && dataloaderConfig[pageId] && dataloaderConfig[pageId].loader) { const staticDataLoaders = []; const { loader } = dataloaderConfig[pageId]; if (Array.isArray(loader)) { dataloaderConfig[pageId].loader.forEach(item => { if (typeof item === 'object') { staticDataLoaders.push(item); } }); } else if (typeof loader === 'object') { // Single prefetch loader config. staticDataLoaders.push(loader); } pageManifest.dataPrefetch = [...(pageManifest.dataPrefetch || []), ...staticDataLoaders]; } }); if (pageManifest.frames && pageManifest.frames.length > 0) { pageManifest.frames = await Promise.all(pageManifest.frames.map((frame) => getPageManifest(frame, options))); // Set static dataloader to dataPrefetch of frames. pageManifest.frames.forEach((frame: Frame) => { if (typeof frame === 'string') return; const title = frame.title || ''; const titleIds = getRouteIdByPage(routeManifest, title); titleIds.forEach((titleId) => { if (dataloaderConfig && dataloaderConfig[titleId] && dataloaderConfig[titleId].loader) { const staticDataLoaders = []; const { loader } = dataloaderConfig[titleId]; if (Array.isArray(loader)) { loader.forEach(item => { if (typeof item === 'object') { staticDataLoaders.push(item); } }); } else if (typeof loader === 'object') { // Single prefetch loader config. staticDataLoaders.push(loader); } frame.dataPrefetch = [...(frame.dataPrefetch || []), ...staticDataLoaders]; } }); }); } if (pageManifest?.pageHeader?.source) { if (!pageManifest.pageHeader.url) { pageManifest.pageHeader = { ...pageManifest.pageHeader, // Generate document logic is different from tabBar. ...(await getTabConfig(pageManifest.pageHeader, true, options)), }; } delete pageManifest.pageHeader.source; } return pageManifest; })); // Delete manifest routes after transform. delete manifest.routes; } return transformManifestKeys(manifest, { isRoot: true }); } export function getMultipleManifest(manifest: PHAManifest): Record<string, PHAManifest> { const multipleManifest = {}; manifest.pages.forEach((page) => { let pageKey = page.key; // Generate manifest for each route. const copiedManifest = cloneDeep(manifest); // Reduce routes config by matched source. copiedManifest.pages = copiedManifest.pages.filter((copiedPage) => { if (copiedPage.frames && !pageKey) { // TODO: frames key may conflict with other page keys // https://github.com/raxjs/rax-app/blob/57a536723c8cc9ce7cd4892c1a5990854e395e2c/packages/plugin-rax-pha/src/plugins/AppToManifestPlugin.js#L110 pageKey = page.frames[page.default_frame_index || 0].key; return pageKey === copiedPage.frames[copiedPage.default_frame_index || 0].key; } else { return pageKey === copiedPage.key; } }); // take out the page data prefetch and assign it to the root node if (copiedManifest?.pages![0]?.data_prefetch) { copiedManifest.data_prefetch = copiedManifest.pages[0].data_prefetch; delete copiedManifest.pages[0].data_prefetch; } // take out the page preload and assign it to the root node if (copiedManifest?.pages![0]?.resource_prefetch) { copiedManifest.resource_prefetch = copiedManifest.pages[0].resource_prefetch; delete copiedManifest.pages[0].resource_prefetch; } multipleManifest[pageKey] = copiedManifest; }); return multipleManifest; }