UNPKG

lighthouse

Version:

Automated auditing, performance metrics, and best practices for the web.

494 lines (436 loc) 11.5 kB
/** * @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};