lighthouse
Version:
Automated auditing, performance metrics, and best practices for the web.
494 lines (436 loc) • 11.5 kB
JavaScript
/**
* @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const ALLOWED_DISPLAY_VALUES = [
'fullscreen',
'standalone',
'minimal-ui',
'browser',
];
/**
* All display-mode fallbacks, including when unset, lead to default display mode 'browser'.
* @see https://www.w3.org/TR/2016/WD-appmanifest-20160825/#dfn-default-display-mode
*/
const DEFAULT_DISPLAY_MODE = 'browser';
const ALLOWED_ORIENTATION_VALUES = [
'any',
'natural',
'landscape',
'portrait',
'portrait-primary',
'portrait-secondary',
'landscape-primary',
'landscape-secondary',
];
/**
* @param {*} raw
* @param {boolean=} trim
*/
function parseString(raw, trim) {
let value;
let warning;
if (typeof raw === 'string') {
value = trim ? raw.trim() : raw;
} else {
if (raw !== undefined) {
warning = 'ERROR: expected a string.';
}
value = undefined;
}
return {
raw,
value,
warning,
};
}
/**
* @param {*} raw
*/
function parseColor(raw) {
const color = parseString(raw);
// Finished if color missing or not a string.
if (color.value === undefined) {
return color;
}
return color;
}
/**
* @param {*} jsonInput
*/
function parseName(jsonInput) {
return parseString(jsonInput.name, true);
}
/**
* @param {*} jsonInput
*/
function parseShortName(jsonInput) {
return parseString(jsonInput.short_name, true);
}
/**
* Returns whether the urls are of the same origin. See https://html.spec.whatwg.org/#same-origin
* @param {string} url1
* @param {string} url2
* @return {boolean}
*/
function checkSameOrigin(url1, url2) {
const parsed1 = new URL(url1);
const parsed2 = new URL(url2);
return parsed1.origin === parsed2.origin;
}
/**
* https://www.w3.org/TR/2016/WD-appmanifest-20160825/#start_url-member
* @param {*} jsonInput
* @param {string} manifestUrl
* @param {string} documentUrl
* @return {{raw: any, value: string, warning?: string}}
*/
function parseStartUrl(jsonInput, manifestUrl, documentUrl) {
const raw = jsonInput.start_url;
// 8.10(3) - discard the empty string and non-strings.
if (raw === '') {
return {
raw,
value: documentUrl,
warning: 'ERROR: start_url string empty',
};
}
if (raw === undefined) {
return {
raw,
value: documentUrl,
};
}
if (typeof raw !== 'string') {
return {
raw,
value: documentUrl,
warning: 'ERROR: expected a string.',
};
}
// 8.10(4) - construct URL with raw as input and manifestUrl as the base.
let startUrl;
try {
startUrl = new URL(raw, manifestUrl).href;
} catch (e) {
// 8.10(5) - discard invalid URLs.
return {
raw,
value: documentUrl,
warning: `ERROR: invalid start_url relative to ${manifestUrl}`,
};
}
// 8.10(6) - discard start_urls that are not same origin as documentUrl.
if (!checkSameOrigin(startUrl, documentUrl)) {
return {
raw,
value: documentUrl,
warning: 'ERROR: start_url must be same-origin as document',
};
}
return {
raw,
value: startUrl,
};
}
/**
* @param {*} jsonInput
*/
function parseDisplay(jsonInput) {
const parsedString = parseString(jsonInput.display, true);
const stringValue = parsedString.value;
if (!stringValue) {
return {
raw: jsonInput,
value: DEFAULT_DISPLAY_MODE,
warning: parsedString.warning,
};
}
const displayValue = stringValue.toLowerCase();
if (!ALLOWED_DISPLAY_VALUES.includes(displayValue)) {
return {
raw: jsonInput,
value: DEFAULT_DISPLAY_MODE,
warning: 'ERROR: \'display\' has invalid value ' + displayValue +
`. will fall back to ${DEFAULT_DISPLAY_MODE}.`,
};
}
return {
raw: jsonInput,
value: displayValue,
warning: undefined,
};
}
/**
* @param {*} jsonInput
*/
function parseOrientation(jsonInput) {
const orientation = parseString(jsonInput.orientation, true);
if (orientation.value &&
!ALLOWED_ORIENTATION_VALUES.includes(orientation.value.toLowerCase())) {
orientation.value = undefined;
orientation.warning = 'ERROR: \'orientation\' has an invalid value, will be ignored.';
}
return orientation;
}
/**
* @see https://www.w3.org/TR/2016/WD-appmanifest-20160825/#src-member
* @param {*} raw
* @param {string} manifestUrl
*/
function parseIcon(raw, manifestUrl) {
// 9.4(3)
const src = parseString(raw.src, true);
// 9.4(4) - discard if trimmed value is the empty string.
if (src.value === '') {
src.value = undefined;
}
if (src.value) {
try {
// 9.4(4) - construct URL with manifest URL as the base
src.value = new URL(src.value, manifestUrl).href;
} catch (_) {
// 9.4 "This algorithm will return a URL or undefined."
src.warning = `ERROR: invalid icon url will be ignored: '${raw.src}'`;
src.value = undefined;
}
}
const type = parseString(raw.type, true);
const parsedPurpose = parseString(raw.purpose);
const purpose = {
raw: raw.purpose,
value: ['any'],
/** @type {string|undefined} */
warning: undefined,
};
if (parsedPurpose.value !== undefined) {
purpose.value = parsedPurpose.value.split(/\s+/).map(value => value.toLowerCase());
}
const density = {
raw: raw.density,
value: 1,
/** @type {string|undefined} */
warning: undefined,
};
if (density.raw !== undefined) {
density.value = parseFloat(density.raw);
if (isNaN(density.value) || !isFinite(density.value) || density.value <= 0) {
density.value = 1;
density.warning = 'ERROR: icon density cannot be NaN, +∞, or less than or equal to +0.';
}
}
let sizes;
const parsedSizes = parseString(raw.sizes);
if (parsedSizes.value !== undefined) {
/** @type {Set<string>} */
const set = new Set();
parsedSizes.value.trim().split(/\s+/).forEach(size => set.add(size.toLowerCase()));
sizes = {
raw: raw.sizes,
value: set.size > 0 ? Array.from(set) : undefined,
warning: undefined,
};
} else {
sizes = {...parsedSizes, value: undefined};
}
return {
raw,
value: {
src,
type,
density,
sizes,
purpose,
},
warning: undefined,
};
}
/**
* @param {*} jsonInput
* @param {string} manifestUrl
*/
function parseIcons(jsonInput, manifestUrl) {
const raw = jsonInput.icons;
if (raw === undefined) {
return {
raw,
/** @type {Array<ReturnType<typeof parseIcon>>} */
value: [],
warning: undefined,
};
}
if (!Array.isArray(raw)) {
return {
raw,
/** @type {Array<ReturnType<typeof parseIcon>>} */
value: [],
warning: 'ERROR: \'icons\' expected to be an array but is not.',
};
}
const parsedIcons = raw
// 9.6(3)(1)
.filter(icon => icon.src !== undefined)
// 9.6(3)(2)(1)
.map(icon => parseIcon(icon, manifestUrl));
// NOTE: we still lose the specific message on these icons, but it's not possible to surface them
// without a massive change to the structure and paradigms of `manifest-parser`.
const ignoredIconsWithWarnings = parsedIcons
.filter(icon => {
const possibleWarnings = [icon.warning, icon.value.type.warning, icon.value.src.warning,
icon.value.sizes.warning, icon.value.density.warning].filter(Boolean);
const hasSrc = !!icon.value.src.value;
return !!possibleWarnings.length && !hasSrc;
});
const value = parsedIcons
// 9.6(3)(2)(2)
.filter(parsedIcon => parsedIcon.value.src.value !== undefined);
return {
raw,
value,
warning: ignoredIconsWithWarnings.length ?
'WARNING: Some icons were ignored due to warnings.' : undefined,
};
}
/**
* @param {*} raw
*/
function parseApplication(raw) {
const platform = parseString(raw.platform, true);
const id = parseString(raw.id, true);
// 10.2.(2) and 10.2.(3)
const appUrl = parseString(raw.url, true);
if (appUrl.value) {
try {
// 10.2.(4) - attempt to construct URL.
appUrl.value = new URL(appUrl.value).href;
} catch (e) {
appUrl.value = undefined;
appUrl.warning = `ERROR: invalid application URL ${raw.url}`;
}
}
return {
raw,
value: {
platform,
id,
url: appUrl,
},
warning: undefined,
};
}
/**
* @param {*} jsonInput
*/
function parseRelatedApplications(jsonInput) {
const raw = jsonInput.related_applications;
if (raw === undefined) {
return {
raw,
value: undefined,
warning: undefined,
};
}
if (!Array.isArray(raw)) {
return {
raw,
value: undefined,
warning: 'ERROR: \'related_applications\' expected to be an array but is not.',
};
}
// TODO(bckenny): spec says to skip apps missing `platform`, so debug messages
// on individual apps are lost. Warn instead?
const value = raw
.filter(application => !!application.platform)
.map(parseApplication)
.filter(parsedApp => !!parsedApp.value.id.value || !!parsedApp.value.url.value);
return {
raw,
value,
warning: undefined,
};
}
/**
* @param {*} jsonInput
*/
function parsePreferRelatedApplications(jsonInput) {
const raw = jsonInput.prefer_related_applications;
let value;
let warning;
if (typeof raw === 'boolean') {
value = raw;
} else {
if (raw !== undefined) {
warning = 'ERROR: \'prefer_related_applications\' expected to be a boolean.';
}
value = undefined;
}
return {
raw,
value,
warning,
};
}
/**
* @param {*} jsonInput
*/
function parseThemeColor(jsonInput) {
return parseColor(jsonInput.theme_color);
}
/**
* @param {*} jsonInput
*/
function parseBackgroundColor(jsonInput) {
return parseColor(jsonInput.background_color);
}
/**
* Parse a manifest from the given inputs.
* @param {string} string Manifest JSON string.
* @param {string} manifestUrl URL of manifest file.
* @param {string} documentUrl URL of document containing manifest link element.
*/
function parseManifest(string, manifestUrl, documentUrl) {
if (manifestUrl === undefined || documentUrl === undefined) {
throw new Error('Manifest and document URLs required for manifest parsing.');
}
let jsonInput;
try {
jsonInput = JSON.parse(string);
} catch (e) {
return {
raw: string,
value: undefined,
warning: 'ERROR: file isn\'t valid JSON: ' + e,
url: manifestUrl,
};
}
const manifest = {
name: parseName(jsonInput),
short_name: parseShortName(jsonInput),
start_url: parseStartUrl(jsonInput, manifestUrl, documentUrl),
display: parseDisplay(jsonInput),
orientation: parseOrientation(jsonInput),
icons: parseIcons(jsonInput, manifestUrl),
related_applications: parseRelatedApplications(jsonInput),
prefer_related_applications: parsePreferRelatedApplications(jsonInput),
theme_color: parseThemeColor(jsonInput),
background_color: parseBackgroundColor(jsonInput),
};
/** @type {string|undefined} */
let manifestUrlWarning;
try {
const manifestUrlParsed = new URL(manifestUrl);
if (!manifestUrlParsed.protocol.startsWith('http')) {
manifestUrlWarning = `WARNING: manifest URL not available over a valid network protocol`;
}
} catch (_) {
manifestUrlWarning = `ERROR: invalid manifest URL: '${manifestUrl}'`;
}
return {
raw: string,
value: manifest,
warning: manifestUrlWarning,
url: manifestUrl,
};
}
export {parseManifest};