From 537551874b789b9cf341b05a36114140db7605d6 Mon Sep 17 00:00:00 2001 From: Nick Date: Sun, 23 Jun 2019 18:35:14 -0400 Subject: [PATCH] feat: analytics modules backend + admin panel --- .nvmrc | 2 +- client/components/admin.vue | 4 + client/components/admin/admin-analytics.vue | 178 ++++++++++++++++++ client/components/admin/admin-general.vue | 32 +--- client/components/admin/admin-storage.vue | 7 +- .../analytics-mutation-save-providers.gql | 12 ++ .../analytics/analytics-query-providers.gql | 17 ++ client/static/svg/icon-line-chart.svg | 29 +++ package.json | 2 +- server/core/kernel.js | 1 + server/db/migrations-sqlite/2.0.0-beta.205.js | 13 ++ server/db/migrations/2.0.0-beta.205.js | 17 ++ server/graph/resolvers/analytics.js | 56 ++++++ server/graph/schemas/analytics.graphql | 53 ++++++ server/models/analytics.js | 96 ++++++++++ .../analytics/azureinsights/definition.yml | 23 +++ .../modules/analytics/countly/definition.yml | 45 +++++ .../analytics/elasticapm/definition.yml | 36 ++++ .../modules/analytics/fathom/definition.yml | 34 ++++ .../analytics/fullstory/definition.yml | 31 +++ .../modules/analytics/google/definition.yml | 23 +++ server/modules/analytics/gtm/definition.yml | 26 +++ .../modules/analytics/hotjar/definition.yml | 25 +++ server/views/master.pug | 28 +-- server/views/setup.pug | 22 +-- 25 files changed, 752 insertions(+), 60 deletions(-) create mode 100644 client/components/admin/admin-analytics.vue create mode 100644 client/graph/admin/analytics/analytics-mutation-save-providers.gql create mode 100644 client/graph/admin/analytics/analytics-query-providers.gql create mode 100644 client/static/svg/icon-line-chart.svg create mode 100644 server/db/migrations-sqlite/2.0.0-beta.205.js create mode 100644 server/db/migrations/2.0.0-beta.205.js create mode 100644 server/graph/resolvers/analytics.js create mode 100644 server/graph/schemas/analytics.graphql create mode 100644 server/models/analytics.js create mode 100644 server/modules/analytics/azureinsights/definition.yml create mode 100644 server/modules/analytics/countly/definition.yml create mode 100644 server/modules/analytics/elasticapm/definition.yml create mode 100644 server/modules/analytics/fathom/definition.yml create mode 100644 server/modules/analytics/fullstory/definition.yml create mode 100644 server/modules/analytics/google/definition.yml create mode 100644 server/modules/analytics/gtm/definition.yml create mode 100644 server/modules/analytics/hotjar/definition.yml diff --git a/.nvmrc b/.nvmrc index cc5875fa..f0da0944 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v10.15.3 +v10.16.0 diff --git a/client/components/admin.vue b/client/components/admin.vue index 40c9bcfc..b198170b 100644 --- a/client/components/admin.vue +++ b/client/components/admin.vue @@ -50,6 +50,9 @@ template(v-if='hasPermission(`manage:system`)') v-divider.my-2 v-subheader.pl-4 {{ $t('admin:nav.modules') }} + v-list-tile(to='/analytics') + v-list-tile-avatar: v-icon timeline + v-list-tile-title {{ $t('admin:analytics.title') }} v-list-tile(to='/auth') v-list-tile-avatar: v-icon lock_outline v-list-tile-title {{ $t('admin:auth.title') }} @@ -143,6 +146,7 @@ const router = new VueRouter({ { path: '/groups/:id', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-groups-edit.vue') }, { path: '/users', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-users.vue') }, { path: '/users/:id', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-users-edit.vue') }, + { path: '/analytics', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-analytics.vue') }, { path: '/auth', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-auth.vue') }, { path: '/rendering', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-rendering.vue') }, { path: '/editor', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-editor.vue') }, diff --git a/client/components/admin/admin-analytics.vue b/client/components/admin/admin-analytics.vue new file mode 100644 index 00000000..800e5ed3 --- /dev/null +++ b/client/components/admin/admin-analytics.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/client/components/admin/admin-general.vue b/client/components/admin/admin-general.vue index c8a2c689..ab1249ce 100644 --- a/client/components/admin/admin-general.vue +++ b/client/components/admin/admin-general.vue @@ -99,36 +99,6 @@ v-spacer v-chip(label, color='white', small).primary--text coming soon v-card-text - v-switch( - label='Analytics' - color='primary' - v-model='config.featureAnalytics' - persistent-hint - hint='Enable site analytics using service provider.' - disabled - ) - v-select.mt-3( - outline - label='Analytics Service Provider' - :items='analyticsServices' - v-model='config.analyticsService' - prepend-icon='subdirectory_arrow_right' - persistent-hint - hint='Automatically add tracking code for services like Google Analytics.' - disabled - ) - v-text-field.mt-2( - v-if='config.analyticsService !== ``' - outline - label='Property Tracking ID' - :counter='255' - v-model='config.analyticsId' - prepend-icon='timeline' - persistent-hint - hint='A unique identifier provided by your analytics service provider.' - ) - - v-divider.mt-3 v-switch( label='Asset Image Optimization' color='primary' @@ -191,7 +161,7 @@ export default { return { analyticsServices: [ { text: 'None', value: '' }, - { text: 'Elasticsearch APM', value: 'elk' }, + { text: 'Elasticsearch APM RUM', value: 'elk' }, { text: 'Google Analytics', value: 'ga' }, { text: 'Google Tag Manager', value: 'gtm' } ], diff --git a/client/components/admin/admin-storage.vue b/client/components/admin/admin-storage.vue index 7fa2d98e..43ca67e2 100644 --- a/client/components/admin/admin-storage.vue +++ b/client/components/admin/admin-storage.vue @@ -166,7 +166,7 @@ i18next.caption.mt-3(path='admin:storage.syncScheduleCurrent', tag='div') strong(place='schedule') {{getDefaultSchedule(target.syncInterval)}} i18next.caption(path='admin:storage.syncScheduleDefault', tag='div') - strong {{getDefaultSchedule(target.syncIntervalDefault)}} + strong(place='schedule') {{getDefaultSchedule(target.syncIntervalDefault)}} template(v-if='target.actions && target.actions.length > 0') v-divider.mt-3 @@ -216,7 +216,9 @@ export default { runningAction: false, runningActionHandler: '', selectedTarget: '', - target: {}, + target: { + supportedModes: [] + }, targets: [], status: [] } @@ -265,6 +267,7 @@ export default { this.$store.commit(`loadingStop`, 'admin-storage-savetargets') }, getDefaultSchedule(val) { + if (!val) { return 'N/A' } return moment.duration(val).format('y [years], M [months], d [days], h [hours], m [minutes]') }, async executeAction(targetKey, handler) { diff --git a/client/graph/admin/analytics/analytics-mutation-save-providers.gql b/client/graph/admin/analytics/analytics-mutation-save-providers.gql new file mode 100644 index 00000000..09cc9a49 --- /dev/null +++ b/client/graph/admin/analytics/analytics-mutation-save-providers.gql @@ -0,0 +1,12 @@ +mutation($providers: [AnalyticsProviderInput]!) { + analytics { + updateProviders(providers: $providers) { + responseResult { + succeeded + errorCode + slug + message + } + } + } +} diff --git a/client/graph/admin/analytics/analytics-query-providers.gql b/client/graph/admin/analytics/analytics-query-providers.gql new file mode 100644 index 00000000..4402fc9c --- /dev/null +++ b/client/graph/admin/analytics/analytics-query-providers.gql @@ -0,0 +1,17 @@ +query { + analytics { + providers { + isEnabled + key + title + description + isAvailable + logo + website + config { + key + value + } + } + } +} diff --git a/client/static/svg/icon-line-chart.svg b/client/static/svg/icon-line-chart.svg new file mode 100644 index 00000000..4e887fd0 --- /dev/null +++ b/client/static/svg/icon-line-chart.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/package.json b/package.json index b3bb3f7c..55620e59 100644 --- a/package.json +++ b/package.json @@ -226,7 +226,7 @@ "graphql-voyager": "1.0.0-rc.27", "hammerjs": "2.0.8", "html-webpack-plugin": "3.2.0", - "html-webpack-pug-plugin": "0.3.0", + "html-webpack-pug-plugin": "2.0.0", "i18next-xhr-backend": "3.0.0", "ignore-loader": "0.1.2", "js-cookie": "2.2.0", diff --git a/server/core/kernel.js b/server/core/kernel.js index 32fac63d..f1f87860 100644 --- a/server/core/kernel.js +++ b/server/core/kernel.js @@ -63,6 +63,7 @@ module.exports = { * Post-Master Boot Sequence */ async postBootMaster() { + await WIKI.models.analytics.refreshProvidersFromDisk() await WIKI.models.authentication.refreshStrategiesFromDisk() await WIKI.models.editors.refreshEditorsFromDisk() await WIKI.models.loggers.refreshLoggersFromDisk() diff --git a/server/db/migrations-sqlite/2.0.0-beta.205.js b/server/db/migrations-sqlite/2.0.0-beta.205.js new file mode 100644 index 00000000..934abfbc --- /dev/null +++ b/server/db/migrations-sqlite/2.0.0-beta.205.js @@ -0,0 +1,13 @@ +exports.up = knex => { + return knex.schema + .createTable('analytics', table => { + table.string('key').notNullable().primary() + table.boolean('isEnabled').notNullable().defaultTo(false) + table.json('config').notNullable() + }) +} + +exports.down = knex => { + return knex.schema + .dropTableIfExists('analytics') +} diff --git a/server/db/migrations/2.0.0-beta.205.js b/server/db/migrations/2.0.0-beta.205.js new file mode 100644 index 00000000..371f58dc --- /dev/null +++ b/server/db/migrations/2.0.0-beta.205.js @@ -0,0 +1,17 @@ +exports.up = knex => { + const dbCompat = { + charset: (WIKI.config.db.type === `mysql` || WIKI.config.db.type === `mariadb`) + } + return knex.schema + .createTable('analytics', table => { + if (dbCompat.charset) { table.charset('utf8mb4') } + table.string('key').notNullable().primary() + table.boolean('isEnabled').notNullable().defaultTo(false) + table.json('config').notNullable() + }) +} + +exports.down = knex => { + return knex.schema + .dropTableIfExists('analytics') +} diff --git a/server/graph/resolvers/analytics.js b/server/graph/resolvers/analytics.js new file mode 100644 index 00000000..f8dc2821 --- /dev/null +++ b/server/graph/resolvers/analytics.js @@ -0,0 +1,56 @@ +const _ = require('lodash') +const graphHelper = require('../../helpers/graph') + +/* global WIKI */ + +module.exports = { + Query: { + async analytics() { return {} } + }, + Mutation: { + async analytics() { return {} } + }, + AnalyticsQuery: { + async providers(obj, args, context, info) { + let providers = await WIKI.models.analytics.getProviders(args.isEnabled) + providers = providers.map(stg => { + const providerInfo = _.find(WIKI.data.analytics, ['key', stg.key]) || {} + return { + ...providerInfo, + ...stg, + config: _.sortBy(_.transform(stg.config, (res, value, key) => { + const configData = _.get(providerInfo.props, key, {}) + res.push({ + key, + value: JSON.stringify({ + ...configData, + value + }) + }) + }, []), 'key') + } + }) + return providers + } + }, + AnalyticsMutation: { + async updateProviders(obj, args, context) { + try { + for (let str of args.providers) { + await WIKI.models.analytics.query().patch({ + isEnabled: str.isEnabled, + config: _.reduce(str.config, (result, value, key) => { + _.set(result, `${value.key}`, _.get(JSON.parse(value.value), 'v', null)) + return result + }, {}) + }).where('key', str.key) + } + return { + responseResult: graphHelper.generateSuccess('Providers updated successfully') + } + } catch (err) { + return graphHelper.generateError(err) + } + } + } +} diff --git a/server/graph/schemas/analytics.graphql b/server/graph/schemas/analytics.graphql new file mode 100644 index 00000000..8783ca2d --- /dev/null +++ b/server/graph/schemas/analytics.graphql @@ -0,0 +1,53 @@ +# =============================================== +# ANALYTICS +# =============================================== + +extend type Query { + analytics: AnalyticsQuery +} + +extend type Mutation { + analytics: AnalyticsMutation +} + +# ----------------------------------------------- +# QUERIES +# ----------------------------------------------- + +type AnalyticsQuery { + providers( + isEnabled: Boolean + ): [AnalyticsProvider] +} + +# ----------------------------------------------- +# MUTATIONS +# ----------------------------------------------- + +type AnalyticsMutation { + updateProviders( + providers: [AnalyticsProviderInput]! + ): DefaultResponse @auth(requires: ["manage:system"]) +} + +# ----------------------------------------------- +# TYPES +# ----------------------------------------------- + +type AnalyticsProvider { + isEnabled: Boolean! + key: String! + props: [String] + title: String! + description: String + isAvailable: Boolean + logo: String + website: String + icon: String + config: [KeyValuePair] @auth(requires: ["manage:system"]) +} +input AnalyticsProviderInput { + isEnabled: Boolean! + key: String! + config: [KeyValuePairInput] +} diff --git a/server/models/analytics.js b/server/models/analytics.js new file mode 100644 index 00000000..6f1b8962 --- /dev/null +++ b/server/models/analytics.js @@ -0,0 +1,96 @@ +const Model = require('objection').Model +const fs = require('fs-extra') +const path = require('path') +const _ = require('lodash') +const yaml = require('js-yaml') +const commonHelper = require('../helpers/common') + +/* global WIKI */ + +/** + * Analytics model + */ +module.exports = class Analytics extends Model { + static get tableName() { return 'analytics' } + static get idColumn() { return 'key' } + + static get jsonSchema () { + return { + type: 'object', + required: ['key', 'isEnabled'], + + properties: { + key: {type: 'string'}, + isEnabled: {type: 'boolean'} + } + } + } + + static get jsonAttributes() { + return ['config'] + } + + static async getProviders(isEnabled) { + const providers = await WIKI.models.analytics.query().where(_.isBoolean(isEnabled) ? { isEnabled } : {}) + return _.sortBy(providers, ['key']) + } + + static async refreshProvidersFromDisk() { + let trx + try { + const dbProviders = await WIKI.models.analytics.query() + + // -> Fetch definitions from disk + const analyticsDirs = await fs.readdir(path.join(WIKI.SERVERPATH, 'modules/analytics')) + let diskProviders = [] + for (let dir of analyticsDirs) { + const def = await fs.readFile(path.join(WIKI.SERVERPATH, 'modules/analytics', dir, 'definition.yml'), 'utf8') + diskProviders.push(yaml.safeLoad(def)) + } + WIKI.data.analytics = diskProviders.map(provider => ({ + ...provider, + props: commonHelper.parseModuleProps(provider.props) + })) + + let newProviders = [] + for (let provider of WIKI.data.analytics) { + if (!_.some(dbProviders, ['key', provider.key])) { + newProviders.push({ + key: provider.key, + isEnabled: false, + config: _.transform(provider.props, (result, value, key) => { + _.set(result, key, value.default) + return result + }, {}) + }) + } else { + const providerConfig = _.get(_.find(dbProviders, ['key', provider.key]), 'config', {}) + await WIKI.models.analytics.query().patch({ + config: _.transform(provider.props, (result, value, key) => { + if (!_.has(result, key)) { + _.set(result, key, value.default) + } + return result + }, providerConfig) + }).where('key', provider.key) + } + } + if (newProviders.length > 0) { + trx = await WIKI.models.Objection.transaction.start(WIKI.models.knex) + for (let provider of newProviders) { + await WIKI.models.analytics.query(trx).insert(provider) + } + await trx.commit() + WIKI.logger.info(`Loaded ${newProviders.length} new analytics providers: [ OK ]`) + } else { + WIKI.logger.info(`No new analytics providers found: [ SKIPPED ]`) + } + } catch (err) { + WIKI.logger.error(`Failed to scan or load new analytics providers: [ FAILED ]`) + WIKI.logger.error(err) + if (trx) { + trx.rollback() + } + } + } +} diff --git a/server/modules/analytics/azureinsights/definition.yml b/server/modules/analytics/azureinsights/definition.yml new file mode 100644 index 00000000..4d7b39ef --- /dev/null +++ b/server/modules/analytics/azureinsights/definition.yml @@ -0,0 +1,23 @@ +key: azureinsights +title: Azure Application Insights +description: Application Insights is an extensible Application Performance Management (APM) service for web developers on multiple platforms. +author: requarks.io +logo: https://static.requarks.io/logo/azure.svg +website: https://azure.microsoft.com/en-us/services/monitor/ +isAvailable: true +props: + instrumentationKey: + type: String + title: Instrumentation Key + hint: Found in the Azure Portal in your Application Insights resource panel + order: 1 +codeHead: | + diff --git a/server/modules/analytics/countly/definition.yml b/server/modules/analytics/countly/definition.yml new file mode 100644 index 00000000..163ebef7 --- /dev/null +++ b/server/modules/analytics/countly/definition.yml @@ -0,0 +1,45 @@ +key: countly +title: Countly +description: Countly is the best analytics platform to understand and enhance customer journeys in web, desktop and mobile applications. +author: requarks.io +logo: https://static.requarks.io/logo/countly.svg +website: https://count.ly/ +isAvailable: true +props: + appKey: + type: String + title: App Key + hint: The App Key found under Management > Applications + order: 1 + serverUrl: + type: String + title: Server URL + hint: The Count.ly server to report to. e.g. https://us-example.count.ly + order: 2 +codeHead: | + diff --git a/server/modules/analytics/elasticapm/definition.yml b/server/modules/analytics/elasticapm/definition.yml new file mode 100644 index 00000000..c3907812 --- /dev/null +++ b/server/modules/analytics/elasticapm/definition.yml @@ -0,0 +1,36 @@ +key: elasticapm +title: Elasticsearch APM RUM +description: Real User Monitoring captures user interaction with clients such as web browsers. +author: requarks.io +logo: https://static.requarks.io/logo/elasticsearch-apm.svg +website: https://www.elastic.co/solutions/apm +isAvailable: true +props: + serverUrl: + type: String + title: APM Server URL + hint: The full URL to your APM server, including the port + default: http://apm.example.com:8200 + order: 1 + serviceName: + type: String + title: Service Name + hint: The name of the client reported to APM + default: wiki-js + order: 2 + environment: + type: String + title: Environment + hint: e.g. production/development/test + default: '' + order: 3 +codeHead: | + + + diff --git a/server/modules/analytics/fathom/definition.yml b/server/modules/analytics/fathom/definition.yml new file mode 100644 index 00000000..d193b9eb --- /dev/null +++ b/server/modules/analytics/fathom/definition.yml @@ -0,0 +1,34 @@ +key: fathom +title: Fathom +description: Fathom Analytics provides simple, useful website stats without tracking or storing personal data of your users. +author: requarks.io +logo: https://static.requarks.io/logo/fathom.svg +website: https://usefathom.com/ +isAvailable: true +props: + host: + type: String + title: Fathom Server Host + hint: The hostname / ip adress where Fathom is installed, without the trailing slash. e.g. https://fathom.example.com + order: 1 + siteId: + type: String + title: Site ID + hint: The alphanumeric identifier of your site + order: 2 +codeHead: | + + + diff --git a/server/modules/analytics/fullstory/definition.yml b/server/modules/analytics/fullstory/definition.yml new file mode 100644 index 00000000..b76b6833 --- /dev/null +++ b/server/modules/analytics/fullstory/definition.yml @@ -0,0 +1,31 @@ +key: fullstory +title: FullStory +description: FullStory is your digital experience analytics platform for on-the-fly funnels, pixel-perfect replay, custom events, heat maps, advanced search, Dev Tools, and more. +author: requarks.io +logo: https://static.requarks.io/logo/fullstory.svg +website: https://www.fullstory.com +isAvailable: true +props: + org: + type: String + title: Organization ID + hint: A 5 alphanumeric identifier, e.g. XXXXX + order: 1 +codeHead: | + diff --git a/server/modules/analytics/google/definition.yml b/server/modules/analytics/google/definition.yml new file mode 100644 index 00000000..a6907f9d --- /dev/null +++ b/server/modules/analytics/google/definition.yml @@ -0,0 +1,23 @@ +key: google +title: Google Analytics +description: Google specializes in Internet-related services and products, which include online advertising technologies, search engine, cloud computing, software, and hardware. +author: requarks.io +logo: https://static.requarks.io/logo/google-analytics.svg +website: https://analytics.google.com/ +isAvailable: true +props: + propertyTrackingId: + type: String + title: Property Tracking ID + hint: UA-XXXXXXX-X + order: 1 +codeHead: | + + + diff --git a/server/modules/analytics/gtm/definition.yml b/server/modules/analytics/gtm/definition.yml new file mode 100644 index 00000000..51fcb3b9 --- /dev/null +++ b/server/modules/analytics/gtm/definition.yml @@ -0,0 +1,26 @@ +key: gtm +title: Google Tag Manager +description: Google specializes in Internet-related services and products, which include online advertising technologies, search engine, cloud computing, software, and hardware. +author: requarks.io +logo: https://static.requarks.io/logo/google-tag-manager.svg +website: https://tagmanager.google.com +isAvailable: true +props: + containerTrackingId: + type: String + title: Container Tracking ID + hint: GTM-XXXXXXX + order: 1 +codeHead: | + + + +codeBodyStart: | + + + diff --git a/server/modules/analytics/hotjar/definition.yml b/server/modules/analytics/hotjar/definition.yml new file mode 100644 index 00000000..f3a452b8 --- /dev/null +++ b/server/modules/analytics/hotjar/definition.yml @@ -0,0 +1,25 @@ +key: hotjar +title: Hotjar +description: Hotjar is the fast & visual way to understand your users, providing everything your team needs to uncover insights and make the right changes to your site. +author: requarks.io +logo: https://static.requarks.io/logo/hotjar.svg +website: https://www.hotjar.com +isAvailable: true +props: + siteId: + type: String + title: Site ID + hint: A numeric identifier of your site + order: 1 +codeHead: | + + diff --git a/server/views/master.pug b/server/views/master.pug index fd8d6810..6014d3bf 100644 --- a/server/views/master.pug +++ b/server/views/master.pug @@ -7,9 +7,9 @@ html meta(name='theme-color', content='#333333') meta(name='msapplication-TileColor', content='#333333') meta(name='msapplication-TileImage', content='/favicons/ms-icon-144x144.png') - + title= pageMeta.title + ' | ' + config.title - + //- SEO / OpenGraph meta(name='description', content=pageMeta.description) meta(property='og:title', content=pageMeta.title) @@ -18,7 +18,7 @@ html meta(property='og:image', content=pageMeta.image) meta(property='og:url', content=pageMeta.url) meta(property='og:site_name', content=config.title) - + //- Favicon each favsize in [57, 60, 72, 76, 114, 120, 144, 152, 180] link(rel='apple-touch-icon', sizes=favsize + 'x' + favsize, href='/favicons/apple-icon-' + favsize + 'x' + favsize + '.png') @@ -26,32 +26,32 @@ html each favsize in [32, 96, 16] link(rel='icon', type='image/png', sizes=favsize + 'x' + favsize, href='/favicons/favicon-' + favsize + 'x' + favsize + '.png') link(rel='manifest', href='/manifest.json') - + //- Site Properties script. var siteConfig = !{JSON.stringify(siteConfig)}; var siteLangs = !{JSON.stringify(langs)} - + //- CSS - + //- JS - - + + script( type='text/javascript' src='/js/runtime.js' ) - - - + + + script( type='text/javascript' src='/js/app.js' ) - - + + block head - + body block body diff --git a/server/views/setup.pug b/server/views/setup.pug index a28a97b4..80e6da6f 100644 --- a/server/views/setup.pug +++ b/server/views/setup.pug @@ -8,7 +8,7 @@ html meta(name='msapplication-TileColor', content='#333333') meta(name='msapplication-TileImage', content='/favicons/ms-icon-144x144.png') title Wiki.js Setup - + //- Favicon each favsize in [57, 60, 72, 76, 114, 120, 144, 152, 180] link(rel='apple-touch-icon', sizes=favsize + 'x' + favsize, href='/favicons/apple-icon-' + favsize + 'x' + favsize + '.png') @@ -16,31 +16,31 @@ html each favsize in [32, 96, 16] link(rel='icon', type='image/png', sizes=favsize + 'x' + favsize, href='/favicons/favicon-' + favsize + 'x' + favsize + '.png') link(rel='manifest', href='/manifest.json') - + //- Site Lang script. var siteConfig = !{JSON.stringify({ title: config.title })} - + //- CSS - + //- JS - - + + script( type='text/javascript' src='/js/runtime.js' ) - - - + + + script( type='text/javascript' src='/js/setup.js' ) - - + + body #root setup(telemetry-id=telemetryClientID, wiki-version=packageObj.version)