0
Fork 0
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:
Simon Backx 2022-08-16 15:44:51 +02:00 committed by GitHub
parent 6cf49d8f89
commit c4ea36cfde
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 197 additions and 0 deletions

View file

@ -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

View file

@ -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}),

View file

@ -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);
}

View file

@ -0,0 +1,4 @@
const MemberAttributionAssetsService = require('./service');
const memberAttributionAssets = new MemberAttributionAssetsService();
module.exports = memberAttributionAssets;

View file

@ -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;

View file

@ -0,0 +1,10 @@
{
"extends": "../../../../.eslintrc.js",
"env": {
"browser": true,
"node": false
},
"rules": {
"no-console": "off"
}
}

View file

@ -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);
}
})();

View file

@ -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