diff --git a/spec/pwa_test.js b/spec/pwa_test.js new file mode 100644 index 0000000000000000000000000000000000000000..57375faa48cef24eeac5a62ab84454cc27e14d81 --- /dev/null +++ b/spec/pwa_test.js @@ -0,0 +1,358 @@ +import request from 'supertest' +import { expect } from 'chai' +import * as td from 'testdouble' +import { mockApp, mockConfig, mockFetch, mockRedis } from './util.js' + +describe('Service delivers a generated web-manifest', function () { + before(async function () { + mockConfig({ urls: ['http://ui-server/'] }) + mockRedis() + }) + + after(async function () { + td.reset() + }) + + afterEach(async function () { + await import('../src/redis.js').then(({ client }) => client.flushdb()) + await import('../src/cache.js').then(({ clear }) => clear()) + }) + + it('delivers valid webmanifest with short syntax', async function () { + const app = await mockApp() + mockFetch({ + 'https://ui-server': { + '/api/apps/manifests': { + data: { + capabilities: [], + host: 'all', + productName: 'Valid App Suite', + pwa: { + enabled: true, + name: 'Valid App Suite', + shortName: 'Valid App Suite', + icon: '/themes/default/logo_512.png', + iconWidthHeight: 512, + backgroundColor: 'white' + } + } + } + } + }) + const response = await request(app.server).get('/pwa.json').set('host', 'ui-server') + expect(response.statusCode).to.equal(200) + expect(response.body).to.deep.include({ + name: 'Valid App Suite', + short_name: 'Valid App Suite', + icons: [ + { + src: '/themes/default/logo_512.png', + type: 'image/png', + sizes: '512x512', + purpose: 'any' + } + ], + background_color: 'white' + }) + }) + + it('delivers no manifest with pwa.enabled=false', async function () { + const app = await mockApp() + mockFetch({ + 'https://ui-server': { + '/api/apps/manifests': { + data: { + capabilities: [], + host: 'all', + productName: 'Pwa not enabled' + } + } + } + }) + const response = await request(app.server).get('/pwa.json').set('host', 'ui-server') + expect(response.statusCode).to.equal(200) + expect(response.body).to.deep.equal({}) + }) + + it('delivers valid webmanifest with minimal properties', async function () { + const app = await mockApp() + mockFetch({ + 'https://ui-server': { + '/api/apps/manifests': { + data: { + capabilities: [], + host: 'all', + productName: 'Valid App Suite', + pwa: { + enabled: true, + shortName: 'Short Name', + icon: '/themes/default/logo_512.png', + iconWidthHeight: 512 + } + } + } + } + }) + const response = await request(app.server).get('/pwa.json').set('host', 'ui-server') + expect(response.statusCode).to.equal(200) + expect(response.body).to.deep.include({ + name: 'Short Name', + short_name: 'Short Name', + icons: [ + { + src: '/themes/default/logo_512.png', + type: 'image/png', + sizes: '512x512', + purpose: 'any' + } + ], + background_color: 'white' + }) + }) + + it('must not deliver an invalid manifest when using the short syntax', async function () { + const app = await mockApp() + mockFetch({ + 'https://ui-server': { + '/api/apps/manifests': { + data: { + capabilities: [], + host: 'all', + productName: 'Invalid App Suite', + pwa: { + enabled: true, + name: '123', + shortName: true, + icon: '/themes/default/logo_512.png', + iconWidthHeight: 'noNumbers', + backgroundColor: 'hello' + } + } + } + } + }) + const response = await request(app.server).get('/pwa.json').set('host', 'ui-server') + expect(response.statusCode).to.equal(500) + expect(response.text).to.have.string('Failed to load config for url ui-server: Error:') + }) + + it('must not deliver a manifest with invalid host', async function () { + const app = await mockApp() + const response = await request(app.server).get('/pwa.json').set('host', 'ui-server-not') + expect(response.statusCode).to.equal(500) + expect(response.text).to.equal('Failed to load config for url ui-server-not: Error: Failed to fetch https://ui-server-not/api/apps/manifests?action=config') + }) + + it('delivers valid webmanifest with raw_manifest', async function () { + const app = await mockApp() + mockFetch({ + 'https://ui-server': { + '/api/apps/manifests': { + data: { + capabilities: [], + host: 'all', + productName: 'Valid App Suite', + pwa: { + enabled: true, + raw_manifest: { + name: 'Valid App Suite', + short_name: 'Valid App Suite', + icons: [ + { + src: '/themes/default/logo_512.png', + type: 'image/png', + sizes: '512x512', + purpose: 'any' + } + ], + theme_color: 'white' + } + } + } + } + } + }) + const response = await request(app.server).get('/pwa.json').set('host', 'ui-server') + expect(response.statusCode).to.equal(200) + expect(response.body).to.deep.include({ + name: 'Valid App Suite', + short_name: 'Valid App Suite', + icons: [ + { + src: '/themes/default/logo_512.png', + type: 'image/png', + sizes: '512x512', + purpose: 'any' + } + ], + theme_color: 'white' + }) + }) + + it('must not deliver an invalid manifest with raw_manifest', async function () { + const app = await mockApp() + mockFetch({ + 'https://ui-server': { + '/api/apps/manifests': { + data: { + capabilities: [], + host: 'all', + productName: 'Invalid App Suite', + pwa: { + enabled: true, + raw_manifest: { + name: 123, + shortName: 'Invalid App Suite', + icons: [ + { + type: 'image/fff', + sizes: 'noNumbers', + purpose: 'any' + } + ], + theme_color: 'hello' + } + } + } + } + } + }) + const response = await request(app.server).get('/pwa.json').set('host', 'ui-server') + expect(response.statusCode).to.equal(500) + expect(response.text).to.have.string('Failed to load config for url ui-server: Error:') + }) + + it('must choose raw_manifest over short syntax', async function () { + const app = await mockApp() + mockFetch({ + 'https://ui-server': { + '/api/apps/manifests': { + data: { + capabilities: [], + host: 'all', + productName: 'Invalid App Suite', + pwa: { + enabled: true, + name: 'Short Syntax', + shortName: 'Short Syntax', + icon: '/themes/default/logo_512.png', + iconWidthHeight: 512, + backgroundColor: 'white', + raw_manifest: { + name: 'Raw Manifest', + short_name: 'raw_manifest', + icons: [ + { + src: '/themes/default/logo_512.png', + type: 'image/fff', + sizes: '123x123', + purpose: 'any' + } + ], + theme_color: 'hello' + } + } + } + } + } + }) + const response = await request(app.server).get('/pwa.json').set('host', 'ui-server') + expect(response.statusCode).to.equal(200) + expect(response.body).to.deep.equal({ + name: 'Raw Manifest', + short_name: 'raw_manifest', + icons: [ + { + src: '/themes/default/logo_512.png', + type: 'image/fff', + sizes: '123x123', + purpose: 'any' + } + ], + theme_color: 'hello' + }) + }) + + it('differ between two hosts', async function () { + const app = await mockApp() + mockFetch({ + 'https://ui-server': { + '/api/apps/manifests': { + data: { + capabilities: [], + host: 'all', + productName: 'Invalid App Suite', + pwa: { + enabled: true + } + } + } + }, + 'https://ui-server-other': { + '/api/apps/manifests': { + data: { + capabilities: [], + host: 'all', + productName: 'Invalid App Suite', + pwa: { + enabled: true, + shortName: 'Other Suite' + } + } + } + } + }) + const response = await request(app.server).get('/pwa.json').set('host', 'ui-server') + const responseOther = await request(app.server).get('/pwa.json').set('host', 'ui-server-other') + expect(response.statusCode).to.equal(200) + expect(response.body).to.deep.equal({ + name: 'OX App Suite', + short_name: 'OX App Suite', + icons: [ + { + src: '/themes/default/logo_512.png', + type: 'image/png', + sizes: '512x512', + purpose: 'any' + } + ], + theme_color: 'white', + start_url: '/#pwa=true', + display: 'standalone', + background_color: 'white', + scope: '/', + id: '/#pwa=true', + protocol_handlers: [ + { + protocol: 'mailto', + url: '/#app=io.ox/mail&mailto=%s' + } + ] + }) + expect(responseOther.statusCode).to.equal(200) + expect(responseOther.body).to.deep.equal({ + name: 'Other Suite', + short_name: 'Other Suite', + icons: [ + { + src: '/themes/default/logo_512.png', + type: 'image/png', + sizes: '512x512', + purpose: 'any' + } + ], + theme_color: 'white', + start_url: '/#pwa=true', + display: 'standalone', + background_color: 'white', + scope: '/', + id: '/#pwa=true', + protocol_handlers: [ + { + protocol: 'mailto', + url: '/#app=io.ox/mail&mailto=%s' + } + ] + }) + }) +}) diff --git a/src/cache.js b/src/cache.js index bb56d038833675c1d75e6765cd5de110f0c4ded8..fc79aca007084c93dc5b8d187c9055a8ec2a2e01 100644 --- a/src/cache.js +++ b/src/cache.js @@ -11,12 +11,18 @@ export const fileCacheSizeGauge = new Gauge({ help: 'Number of entries in file cache' }) -export function set (key, value) { +function logAndIgnoreError (err) { + logger.error(err) +} + +export function set (key, value, timeout) { logger.debug(`[Cache] Set ${key}`) if (cache[key] === value) return cache[key] = value + if (timeout) setTimeout(expire, timeout * 1000, key) if (redis.isEnabled()) { - return redis.client.set(key, value).catch(err => logger.error(err)) + if (timeout) return redis.client.set(key, value, 'EX', timeout).catch(logAndIgnoreError) + return redis.client.set(key, value).catch(logAndIgnoreError) } } @@ -40,17 +46,21 @@ export function get (key, fallback) { logger.debug(`[Cache] Resolve from redis: ${key}`) result = JSON.parse(result) cache[key] = result + redis.client.ttl(key).then(timeout => { + if (timeout > 0) setTimeout(expire, timeout * 1000, key) + }) return result } } if (!fallback) return - const fallbackResult = await fallback() + const [fallbackResult, timeout] = await fallback() if (fallbackResult) { logger.debug(`[Cache] Found a getter for: ${key}`) + set(key, JSON.stringify(fallbackResult), timeout) + // overwrite local cache again, as set() will store the stringified version cache[key] = fallbackResult - if (redis.isEnabled()) redis.client.set(key, JSON.stringify(fallbackResult)).catch(err => logger.error(err)) } return fallbackResult })() @@ -113,3 +123,8 @@ export function getFile ({ name, version }, fallback) { cache[key] = promise return promise } + +function expire (key) { + logger.debug(`[Cache] Key ${key} has expired.`) + delete cache[key] +} diff --git a/src/create-app.js b/src/create-app.js index f565cc89a9a3871ef3b6615505223a7695862348..398105dbe7b54850e5dd4e9d4416021d65988f38 100644 --- a/src/create-app.js +++ b/src/create-app.js @@ -12,6 +12,7 @@ import manifestsPlugin from './plugins/manifests.js' import metadataPlugin from './plugins/metadata.js' import redirectsPlugin from './plugins/redirects.js' import serveFilePlugin from './plugins/serve-files.js' +import serveWebmanifest from './plugins/webmanifest.js' import { logger } from './logger.js' const swaggerDocument = yaml.load(fs.readFileSync('./src/swagger.yaml', 'utf8')) @@ -79,6 +80,7 @@ export async function createApp (basePath) { } await app.register(serveFilePlugin) + await app.register(serveWebmanifest) return app } diff --git a/src/manifests.js b/src/manifests.js index 209a7269f19052f96a96ff4da5ff390f2cc80011..b59c8ffd98d2f08ac3e33a10f91f2cfb6dfdbd72 100644 --- a/src/manifests.js +++ b/src/manifests.js @@ -31,7 +31,7 @@ export async function fetchViteManifests () { } export async function getViteManifests ({ version }) { - return cache.get(getRedisKey({ version, name: 'viteManifests' }), () => fetchViteManifests()) + return cache.get(getRedisKey({ version, name: 'viteManifests' }), async () => [await fetchViteManifests()]) } export function getOxManifests ({ version }) { @@ -50,7 +50,7 @@ export function getOxManifests ({ version }) { export function getDependencies ({ version }) { return cache.get(getRedisKey({ version, name: 'dependencies' }), async () => { const viteManifests = await getViteManifests({ version }) - return viteManifestToDeps(viteManifests) + return [viteManifestToDeps(viteManifests)] }) } diff --git a/src/meta.js b/src/meta.js index c6d5761795570d00fcece6ff9c6fa2a5a16d7cee..17adfb27e691eeef0e7cab4ac82fa17747ec6c50 100644 --- a/src/meta.js +++ b/src/meta.js @@ -27,5 +27,5 @@ export async function fetchMergedMetadata () { } export function getMergedMetadata ({ version }) { - return cache.get(getRedisKey({ version, name: 'mergedMetadata' }), () => fetchMergedMetadata()) + return cache.get(getRedisKey({ version, name: 'mergedMetadata' }), async () => [await fetchMergedMetadata()]) } diff --git a/src/plugins/webmanifest.js b/src/plugins/webmanifest.js new file mode 100644 index 0000000000000000000000000000000000000000..682f76ca1e9bc1e789f53997a5bb0318ea104bd2 --- /dev/null +++ b/src/plugins/webmanifest.js @@ -0,0 +1,104 @@ +import { get } from '../cache.js' +import Validator from '../validator.js' +import { getRedisKey } from '../util.js' + +const template = { + // custom values + name: 'OX App Suite', + short_name: 'OX App Suite', + icons: [ + { + src: '/themes/default/logo_512.png', + type: 'image/png', + sizes: '512x512', + purpose: 'any' + } + ], + // fixed values + // theme_color is taken from index.html and is changed by the theme + theme_color: 'white', + start_url: '/#pwa=true', + display: 'standalone', + background_color: 'white', + scope: '/', + id: '/#pwa=true', + protocol_handlers: [ + { + protocol: 'mailto', + url: '/#app=io.ox/mail&mailto=%s' + } + ] +} + +export default async function serveWebmanifest (fastify) { + fastify.get('/pwa.json', async (req, res) => { + const hostname = req.hostname + try { + const cached = await get(getRedisKey({ name: `cachedManifest:${hostname}` }), async () => [await fetchWebManifest(hostname), 86400]) + res.type('application/manifest+json') + res.send(cached) + } catch (err) { + res.statusCode = 500 + res.send(`Failed to load config for url ${hostname}: ${err}`) + } + }) +} + +async function fetchWebManifest (hostname) { + const url = new URL('/api/apps/manifests?action=config', 'https://' + hostname) + const conf = await fetch(url) + + if (conf.ok) { + const data = (await conf.json()).data + if (String(data.pwa?.enabled) !== 'true') return {} + + const combinedManifest = data.pwa.raw_manifest || Object.assign({}, template, buildManifestFromData(data.pwa)) + const valid = Validator.validate('https://json.schemastore.org/web-manifest-combined.json', combinedManifest) + if (!valid) { + throw new Error(JSON.stringify(Validator.errors[0], null, 2)) + } + const webmanifest = JSON.stringify(combinedManifest, null, 2) + return webmanifest + } else { + throw new Error(`Failed to fetch ${url}`) + } +} + +function buildManifestFromData (userData) { + const manifestData = {} + + if (userData.icons) { + manifestData.icons = [{ + src: userData.icon, + type: getTypeFromPath(userData.icon), + sizes: `${userData.iconWidthHeight}x${userData.iconWidthHeight}`, + purpose: 'any' + }] + } + + if (userData.shortName) { + manifestData.short_name = userData.shortName + if (!userData.name) manifestData.name = userData.shortName + } + + if (userData.name) manifestData.name = userData.name + + if (userData.backgroundColor) { + manifestData.background_color = userData.backgroundColor + manifestData.theme_color = userData.backgroundColor + } + + return manifestData +} + +function getTypeFromPath (path) { + const ext = path.split('.').pop() + switch (ext) { + case 'png': + return 'image/png' + case 'svg': + return 'image/svg+xml' + default: + throw new Error('Unsupported file type or no file type.') + } +} diff --git a/src/redis.js b/src/redis.js index 9286d086c4f013731fba08925e1e42ec521cc94f..8474035a6d37cff14043071b88331a0f9a6add53 100644 --- a/src/redis.js +++ b/src/redis.js @@ -18,6 +18,7 @@ const createClient = (type, options = {}) => { publish () {}, subscribe () {}, on () {}, + async ttl () { return -1 }, quit () { } }, { get () { diff --git a/src/schemas/d7-manifest-app-info.json b/src/schemas/d7-manifest-app-info.json new file mode 100644 index 0000000000000000000000000000000000000000..1ae253ba2c3a1c0a590188c41c246e7d52f50891 --- /dev/null +++ b/src/schemas/d7-manifest-app-info.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://json.schemastore.org/web-manifest-app-info.json", + "properties": { + "categories": { + "description": "Describes the expected application categories to which the web application belongs.", + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "description": "Description of the purpose of the web application", + "type": "string" + }, + "iarc_rating_id": { + "description": "Represents an ID value of the IARC rating of the web application. It is intended to be used to determine which ages the web application is appropriate for.", + "type": "string" + }, + "screenshots": { + "description": "The screenshots member is an array of image objects represent the web application in common usage scenarios.", + "type": "array", + "items": { + "$ref": "https://json.schemastore.org/web-manifest.json#/definitions/manifest_image_resource" + } + } + }, + "title": "JSON schema for Web Application manifest files with app information extensions", + "type": "object" +} \ No newline at end of file diff --git a/src/schemas/d7-manifest-combined.json b/src/schemas/d7-manifest-combined.json new file mode 100644 index 0000000000000000000000000000000000000000..ed4019508c42684e71a0c32858c1a9db1fdafe89 --- /dev/null +++ b/src/schemas/d7-manifest-combined.json @@ -0,0 +1,16 @@ +{ + "$id": "https://json.schemastore.org/web-manifest-combined.json", + "$schema": "http://json-schema.org/draft-07/schema", + "allOf": [ + { + "$ref": "https://json.schemastore.org/web-manifest.json" + }, + { + "$ref": "https://json.schemastore.org/web-manifest-app-info.json" + }, + { + "$ref": "https://json.schemastore.org/web-manifest-share-target.json" + } + ], + "title": "JSON schema for Web Application manifest files" +} \ No newline at end of file diff --git a/src/schemas/d7-manifest-share-target.json b/src/schemas/d7-manifest-share-target.json new file mode 100644 index 0000000000000000000000000000000000000000..06421dc08f7d1546661764b009f7173ddd52950d --- /dev/null +++ b/src/schemas/d7-manifest-share-target.json @@ -0,0 +1,116 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "definitions": { + "share_target": { + "type": "object", + "description": "Describes how the application receives share data.", + "properties": { + "action": { + "description": "The URL for the web share target.", + "type": "string" + }, + "method": { + "description": "The HTTP request method for the web share target.", + "type": "string", + "enum": [ + "GET", + "POST", + "get", + "post" + ], + "default": "GET" + }, + "enctype": { + "description": "This member specifies the encoding in the share request.", + "type": "string", + "enum": [ + "application/x-www-form-urlencoded", + "multipart/form-data", + "APPLICATION/X-WWW-FORM-URLENCODED", + "MULTIPART/FORM-DATA" + ], + "default": "application/x-www-form-urlencoded" + }, + "params": { + "description": "Specifies what data gets shared in the request.", + "$ref": "#/definitions/share_target_params" + } + }, + "required": [ + "action", + "params" + ] + }, + "share_target_params": { + "type": "object", + "description": "Specifies what data gets shared in the request.", + "properties": { + "title": { + "description": "The name of the query parameter used for the title of the document being shared.", + "type": "string" + }, + "text": { + "description": "The name of the query parameter used for the message body, made of arbitrary text.", + "type": "string" + }, + "url": { + "description": "The name of the query parameter used for the URL string referring to a resource being shared.", + "type": "string" + }, + "files": { + "description": "Description of how the application receives files from share requests.", + "oneOf": [ + { + "$ref": "#/definitions/share_target_files" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/share_target_files" + } + } + ] + } + } + }, + "share_target_files": { + "type": "object", + "description": "Description of how the application receives files from share requests.", + "properties": { + "name": { + "description": "The name of the form field used to share the files.", + "type": "string" + }, + "accept": { + "description": "Sequence of accepted MIME types or file extensions can be shared to the application.", + "oneOf": [ + { + "type": "string", + "pattern": "^((\\..*)|(.*/.*))$" + }, + { + "type": "array", + "items": { + "type": "string", + "pattern": "^((\\..*)|(.*/.*))$" + } + } + ] + } + }, + "required": [ + "name", + "accept" + ] + } + }, + "$id": "https://json.schemastore.org/web-manifest-share-target.json", + "properties": { + "share_target": { + "description": "Declares the application to be a web share target, and describes how it receives share data.", + "$ref": "#/definitions/share_target" + } + }, + "title": "JSON schema for Web Application manifest files with Web Share Target and Web Share Target Level 2 extensions", + "type": "object" +} \ No newline at end of file diff --git a/src/schemas/d7-manifest.json b/src/schemas/d7-manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..ecadf8981388ca6d30278989471898c04bbdc9d3 --- /dev/null +++ b/src/schemas/d7-manifest.json @@ -0,0 +1,227 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "definitions": { + "manifest_image_resource": { + "type": "object", + "properties": { + "sizes": { + "description": "The sizes member is a string consisting of an unordered set of unique space-separated tokens which are ASCII case-insensitive that represents the dimensions of an image for visual media.", + "oneOf": [ + { + "type": "string", + "pattern": "^[0-9 x]+$" + }, + { + "const": "any" + } + ] + }, + "src": { + "description": "The src member of an image is a URL from which a user agent can fetch the icon's data.", + "type": "string" + }, + "type": { + "description": "The type member of an image is a hint as to the media type of the image.", + "type": "string", + "pattern": "^[\\sa-z0-9\\-+;\\.=\\/]+$" + }, + "purpose": { + "type": "string", + "enum": [ + "monochrome", + "maskable", + "any", + "monochrome maskable", + "monochrome any", + "maskable monochrome", + "maskable any", + "any monochrome", + "any maskable", + "monochrome maskable any", + "monochrome any maskable", + "maskable monochrome any", + "maskable any monochrome", + "any monochrome maskable", + "any maskable monochrome" + ], + "default": "any" + } + }, + "required": [ + "src" + ] + }, + "external_application_resource": { + "type": "object", + "properties": { + "platform": { + "description": "The platform it is associated to.", + "enum": [ + "chrome_web_store", + "play", + "itunes", + "windows" + ] + }, + "url": { + "description": "The URL where the application can be found.", + "type": "string", + "format": "uri" + }, + "id": { + "description": "Information additional to the URL or instead of the URL, depending on the platform.", + "type": "string" + }, + "min_version": { + "description": "Information about the minimum version of an application related to this web app.", + "type": "string" + }, + "fingerprints": { + "description": "An array of fingerprint objects used for verifying the application.", + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + }, + "required": [ + "platform" + ] + }, + "shortcut_item": { + "type": "object", + "description": "A shortcut item represents a link to a key task or page within a web app. A user agent can use these values to assemble a context menu to be displayed by the operating system when a user engages with the web app's icon.", + "properties": { + "name": { + "description": "The name member of a shortcut item is a string that represents the name of the shortcut as it is usually displayed to the user in a context menu.", + "type": "string" + }, + "short_name": { + "description": "The short_name member of a shortcut item is a string that represents a short version of the name of the shortcut. It is intended to be used where there is insufficient space to display the full name of the shortcut.", + "type": "string" + }, + "description": { + "description": "The description member of a shortcut item is a string that allows the developer to describe the purpose of the shortcut.", + "type": "string" + }, + "url": { + "description": "The url member of a shortcut item is a URL within scope of a processed manifest that opens when the associated shortcut is activated.", + "type": "string" + }, + "icons": { + "description": "The icons member of a shortcut item serves as iconic representations of the shortcut in various contexts.", + "type": "array", + "items": { + "$ref": "#/definitions/manifest_image_resource" + } + } + }, + "required": [ + "name", + "url" + ] + } + }, + "$id": "https://json.schemastore.org/web-manifest.json", + "properties": { + "background_color": { + "description": "The background_color member describes the expected background color of the web application.", + "type": "string" + }, + "dir": { + "description": "The base direction of the manifest.", + "enum": [ + "ltr", + "rtl", + "auto" + ], + "default": "auto" + }, + "display": { + "description": "The item represents the developer's preferred display mode for the web application.", + "enum": [ + "fullscreen", + "standalone", + "minimal-ui", + "browser" + ], + "default": "browser" + }, + "icons": { + "description": "The icons member is an array of icon objects that can serve as iconic representations of the web application in various contexts.", + "type": "array", + "items": { + "$ref": "#/definitions/manifest_image_resource" + } + }, + "lang": { + "description": "The primary language for the values of the manifest.", + "type": "string" + }, + "name": { + "description": "The name of the web application.", + "type": "string" + }, + "orientation": { + "description": "The orientation member is a string that serves as the default orientation for all top-level browsing contexts of the web application.", + "enum": [ + "any", + "natural", + "landscape", + "portrait", + "portrait-primary", + "portrait-secondary", + "landscape-primary", + "landscape-secondary" + ] + }, + "prefer_related_applications": { + "description": "Boolean value that is used as a hint for the user agent to say that related applications should be preferred over the web application.", + "type": "boolean" + }, + "related_applications": { + "description": "Array of application accessible to the underlying application platform that has a relationship with the web application.", + "type": "array", + "items": { + "$ref": "#/definitions/external_application_resource" + } + }, + "scope": { + "description": "A string that represents the navigation scope of this web application's application context.", + "type": "string" + }, + "short_name": { + "description": "A string that represents a short version of the name of the web application.", + "type": "string" + }, + "shortcuts": { + "description": "Array of shortcut items that provide access to key tasks within a web application.", + "type": "array", + "items": { + "$ref": "#/definitions/shortcut_item" + } + }, + "start_url": { + "description": "Represents the URL that the developer would prefer the user agent load when the user launches the web application.", + "type": "string" + }, + "theme_color": { + "description": "The theme_color member serves as the default theme color for an application context.", + "type": "string" + }, + "id": { + "description": "A string that represents the id of the web application.", + "type": "string" + } + }, + "title": "JSON schema for Web Application manifest files", + "type": "object" +} \ No newline at end of file diff --git a/src/validator.js b/src/validator.js new file mode 100644 index 0000000000000000000000000000000000000000..54f42248db37c9354697c173b72f103c31cfaa68 --- /dev/null +++ b/src/validator.js @@ -0,0 +1,14 @@ +import Ajv from 'ajv' +import fs from 'node:fs' + +const ajv = new Ajv({ + allErrors: true +}) + +ajv.addSchema( + fs.readdirSync('src/schemas/', 'utf8').map(file => { + return JSON.parse(fs.readFileSync(`src/schemas/${file}`, 'utf8')) + }) +) + +export default ajv