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;
}