mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-20 22:42:53 -05:00
Added member attribution script (#15242)
refs https://github.com/TryGhost/Team/issues/1804 - Adds a script that is only injected when the member attribution alpha flag is enabled - This script builds a history and saves it in localStorage as `ghost-history` that contains something like this: ``` json [ { "time": 1660650730, "path": "/about/" }, { "time": 1660651730, "path": "/welcome/" } ] ``` - Keeps track of the time of every page visit, so we can correctly remove old items. I also considered saving the time separately and clearing the whole history when the saved time is older than 24h, but that would have the side effect that items older than 24h might leak into the history if you visit every 12 hours (to give an example). Plus, having objects in the history might make it easier to add other attributes to the items if we ever want to do that in the future. We also have access to the time between visits. - Added `.eslintrc` configuration for this new frontend script. This makes it easier to spot errors when developing, and follow the same syntax rules as other scripts. In the future it can allow us to require an older ECMA version in the browser script. If we like this pattern, we could also use it for other frontend scripts.
This commit is contained in:
parent
6cf49d8f89
commit
c4ea36cfde
8 changed files with 197 additions and 0 deletions
|
@ -1,3 +1,4 @@
|
|||
core/frontend/src/**/*.js
|
||||
!core/frontend/src/member-attribution/*.js
|
||||
core/frontend/public/**/*.js
|
||||
core/server/lib/members/static/auth/**/*.js
|
||||
|
|
|
@ -19,6 +19,7 @@ const appService = require('./frontend/services/apps');
|
|||
const cardAssetService = require('./frontend/services/card-assets');
|
||||
const commentCountsAssetService = require('./frontend/services/comment-counts-assets');
|
||||
const adminAuthAssetService = require('./frontend/services/admin-auth-assets');
|
||||
const memberAttributionAssetService = require('./frontend/services/member-attribution-assets');
|
||||
const routerManager = require('./frontend/services/routing').routerManager;
|
||||
const settingsCache = require('./shared/settings-cache');
|
||||
const urlService = require('./server/services/url');
|
||||
|
@ -70,6 +71,7 @@ class Bridge {
|
|||
// rebuild asset files
|
||||
await commentCountsAssetService.load();
|
||||
await adminAuthAssetService.load();
|
||||
await memberAttributionAssetService.load();
|
||||
} catch (err) {
|
||||
logging.error(new errors.InternalServerError({
|
||||
message: tpl(messages.activateFailed, {theme: loadedTheme.name}),
|
||||
|
|
|
@ -233,6 +233,10 @@ module.exports = async function ghost_head(options) { // eslint-disable-line cam
|
|||
head.push(`<script defer src="${getAssetUrl('public/comment-counts.min.js')}" data-ghost-comments-counts-api="${urlUtils.getSiteUrl(true)}members/api/comments/counts/"></script>`);
|
||||
}
|
||||
|
||||
if (labs.isSet('memberAttribution')) {
|
||||
head.push(`<script defer src="${getAssetUrl('public/member-attribution.min.js')}"></script>`);
|
||||
}
|
||||
|
||||
if (!_.isEmpty(globalCodeinjection)) {
|
||||
head.push(globalCodeinjection);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
const MemberAttributionAssetsService = require('./service');
|
||||
const memberAttributionAssets = new MemberAttributionAssetsService();
|
||||
|
||||
module.exports = memberAttributionAssets;
|
|
@ -0,0 +1,83 @@
|
|||
// const debug = require('@tryghost/debug')('comments-counts-assets');
|
||||
const Minifier = require('@tryghost/minifier');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logging = require('@tryghost/logging');
|
||||
const config = require('../../../shared/config');
|
||||
|
||||
class MemberAttributionAssetsService {
|
||||
constructor(options = {}) {
|
||||
/** @private */
|
||||
this.src = options.src || path.join(config.get('paths').assetSrc, 'member-attribution');
|
||||
/** @private */
|
||||
this.dest = options.dest || config.getContentPath('public');
|
||||
/** @private */
|
||||
this.minifier = new Minifier({src: this.src, dest: this.dest});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
generateGlobs() {
|
||||
return {
|
||||
'member-attribution.min.js': '*.js'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
generateReplacements() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async minify(globs, options) {
|
||||
try {
|
||||
await this.minifier.minify(globs, options);
|
||||
} catch (error) {
|
||||
if (error.code === 'EACCES') {
|
||||
logging.error('Ghost was not able to write member-attribution asset files due to permissions.');
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async clearFiles() {
|
||||
const rmFile = async (name) => {
|
||||
await fs.unlink(path.join(this.dest, name));
|
||||
};
|
||||
|
||||
const promises = [];
|
||||
for (const key of Object.keys(this.generateGlobs())) {
|
||||
// @deprecated switch this to use fs.rm when we drop support for Node v12
|
||||
promises.push(rmFile(key));
|
||||
}
|
||||
|
||||
// We don't care if removing these files fails as it's valid for them to not exist
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minify, move into the destination directory, and clear existing asset files.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async load() {
|
||||
const globs = this.generateGlobs();
|
||||
const replacements = this.generateReplacements();
|
||||
await this.clearFiles();
|
||||
await this.minify(globs, {replacements});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MemberAttributionAssetsService;
|
10
ghost/core/core/frontend/src/member-attribution/.eslintrc
Normal file
10
ghost/core/core/frontend/src/member-attribution/.eslintrc
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../../../.eslintrc.js",
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": false
|
||||
},
|
||||
"rules": {
|
||||
"no-console": "off"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
// Location where we want to store the history in localStorage
|
||||
const STORAGE_KEY = 'ghost-history';
|
||||
|
||||
// How long before an item should expire (24h)
|
||||
const TIMEOUT = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Maximum amount of urls in the history
|
||||
const LIMIT = 15;
|
||||
|
||||
// History is saved in JSON format, from old to new
|
||||
// Time is saved to be able to exclude old items
|
||||
// [
|
||||
// {
|
||||
// "time": 12341234,
|
||||
// "path": "/about/"
|
||||
// },
|
||||
// {
|
||||
// "time": 12341235,
|
||||
// "path": "/welcome/"
|
||||
// }
|
||||
// ]
|
||||
|
||||
(async function () {
|
||||
try {
|
||||
const storage = window.localStorage;
|
||||
const historyString = storage.getItem(STORAGE_KEY);
|
||||
const currentTime = new Date().getTime();
|
||||
|
||||
// Append current location
|
||||
let history = [];
|
||||
|
||||
if (historyString) {
|
||||
try {
|
||||
history = JSON.parse(historyString);
|
||||
} catch (error) {
|
||||
// Ignore invalid JSON, ans clear history
|
||||
console.warn('[Member Attribution] Error while parsing history', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all items that are expired
|
||||
const firstNotExpiredIndex = history.findIndex((item) => {
|
||||
// Return true to keep all items after and including this item
|
||||
// Return false to remove the item
|
||||
|
||||
if (!item.time || typeof item.time !== 'number') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const difference = currentTime - item.time;
|
||||
|
||||
if (isNaN(item.time) || difference > TIMEOUT) {
|
||||
// Expired or invalid
|
||||
return false;
|
||||
}
|
||||
|
||||
// Valid item (so all following items are also valid by definition)
|
||||
return true;
|
||||
});
|
||||
|
||||
if (firstNotExpiredIndex > 0) {
|
||||
// Remove until the first valid item
|
||||
history.splice(0, firstNotExpiredIndex);
|
||||
} else if (firstNotExpiredIndex === -1) {
|
||||
// Not a single valid item found, remove all
|
||||
history = [];
|
||||
}
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
if (history.length === 0 || history[history.length - 1].path !== currentPath) {
|
||||
history.push({
|
||||
path: currentPath,
|
||||
time: currentTime
|
||||
});
|
||||
} else if (history.length > 0) {
|
||||
history[history.length - 1].time = currentTime;
|
||||
}
|
||||
|
||||
// Restrict length
|
||||
if (history.length > LIMIT) {
|
||||
history = history.slice(-LIMIT);
|
||||
}
|
||||
|
||||
// Save current timestamp
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(history));
|
||||
} catch (error) {
|
||||
console.error('[Member Attribution] Failed with error', error);
|
||||
}
|
||||
})();
|
|
@ -75,6 +75,9 @@ module.exports = function setupSiteApp(routerConfig) {
|
|||
// Comment counts
|
||||
siteApp.use(mw.servePublicFile('built', 'public/comment-counts.min.js', 'application/javascript', constants.ONE_YEAR_S));
|
||||
|
||||
// Member attribution
|
||||
siteApp.use(mw.servePublicFile('built', 'public/member-attribution.min.js', 'application/javascript', constants.ONE_YEAR_S));
|
||||
|
||||
// Serve blog images using the storage adapter
|
||||
siteApp.use(STATIC_IMAGE_URL_PREFIX, mw.handleImageSizes, storage.getStorage('images').serve());
|
||||
// Serve blog media using the storage adapter
|
||||
|
|
Loading…
Add table
Reference in a new issue