ScriptV2: Improved tracker compile.js (#5363)

* Add CLI arguments to compile.js and logging

* Rename folder

* Extract compile code, es modules

* Add a progress bar

* Remove handlebars

* Update report-sizes

* Remove debug code

* inline

* More generous split

* Allow positional arguments for compiling

* Add watch option to compile

* Add compileFile logic

* Most tests run under playwright

* All tests runnable

* Update playwright, remove hack

Note that upgrading to latest failed due to a new test failure. This
might be due to a chrome update.

* Compile script on the fly for tests

* Minor refactor for compileAll

* es module for generate-variants.js

* Allow passing suffix to compilation script - this can be used to generate separate files for comparison

* Fix positionals

* Switch from 2 passes to 1 pass

Did some data analysis on this data:
- Compared to master, 1 pass increased brotli size by 0.7%, 2 passes 0.4%.

Given the change is insignificant enough, we can ignore it for now

The increase is likely due to order of operations in compilation and
some inlined functions getting lost.

* Move customEvents.js to plausible.js

* Clean up API

* Suffix default

* Rework variants.json, globals stored there

* Add more variants under test

* Distribute work across multiple worker threads

Compile time went on my machine from 60s -> 30s

* Fixup server

* Update canSkipCompile

* chore: Bump tracker_script_version to 7

* Update scripts

* Update node-version

* Experiment with adding a small delay to page

* Casing

* rename variable

* Update help text

* features -> compileIds, backport functionality from other branch
This commit is contained in:
Karl-Aksel Puulmann 2025-05-08 10:05:09 +03:00 committed by GitHub
parent 34ae68456b
commit 7265d04a8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 18202 additions and 474 deletions

View File

@ -18,7 +18,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 16
node-version: 23.2.0
- name: Install dependencies
run: npm --prefix ./tracker ci
- name: Install Playwright Browsers

4
.gitignore vendored
View File

@ -43,7 +43,7 @@ npm-debug.log
# Stored hash of source tracker files used in development environment
# to detect changes in /tracker/src and avoid unnecessary compilation.
/tracker/dev-compile/last-hash.txt
/tracker/compiler/last-hash.txt
# test coverage directory
/assets/coverage
@ -84,7 +84,7 @@ plausible-report.xml
/priv/geodb/*.mmdb.gz
# Auto-generated tracker files
/priv/tracker/js/plausible*.js
/priv/tracker/js/plausible*.js*
# Docker volumes
.clickhouse_db_vol*

View File

@ -53,6 +53,19 @@ defmodule PlausibleWeb.TrackerTest do
assert get_script("script.manual.pageleave.js") == get_script("script.manual.js")
end
for variant <- [
"plausible.js",
"script.manual.pageview-props.tagged-events.pageleave.js",
"script.compat.local.exclusions.js",
"script.hash.revenue.file-downloads.pageview-props.js"
] do
test "variant #{variant} is available" do
script = get_script(unquote(variant))
assert String.starts_with?(script, "!function(){var")
assert String.length(script) > 200
end
end
def get_script(filename) do
opts = PlausibleWeb.Tracker.init([])

View File

@ -1,44 +1,67 @@
const uglify = require("uglify-js");
const fs = require('fs')
const path = require('path')
const Handlebars = require("handlebars");
const g = require("generatorics");
const { canSkipCompile } = require("./dev-compile/can-skip-compile");
const { tracker_script_version } = require("./package.json");
import { parseArgs } from 'node:util'
import { compileAll } from './compiler/index.js'
import chokidar from 'chokidar'
if (process.env.NODE_ENV === 'dev' && canSkipCompile()) {
console.info('COMPILATION SKIPPED: No changes detected in tracker dependencies')
process.exit(0)
}
Handlebars.registerHelper('any', function (...args) {
return args.slice(0, -1).some(Boolean)
const { values, positionals } = parseArgs({
options: {
'target': {
type: 'string',
},
'watch': {
type: 'boolean',
short: 'w'
},
'help': {
type: 'boolean',
},
'suffix': {
type: 'string',
default: ''
}
},
allowPositionals: true
})
Handlebars.registerPartial('customEvents', Handlebars.compile(fs.readFileSync(relPath('src/customEvents.js')).toString()))
function relPath(segment) {
return path.join(__dirname, segment)
if (values.help) {
console.log('Usage: node compile.js [...compile-ids] [flags]')
console.log('Options:')
console.log(' --target hash,outbound-links,exclusions Only compile variants that contain all specified compile-ids')
console.log(' --watch, -w Watch src/ directory for changes and recompile')
console.log(' --suffix, -s Suffix to add to the output file name. Used for testing script size changes')
console.log(' --help Show this help message')
process.exit(0);
}
function compilefile(input, output, templateVars = {}) {
const code = fs.readFileSync(input).toString()
const template = Handlebars.compile(code)
const rendered = template({ ...templateVars, TRACKER_SCRIPT_VERSION: tracker_script_version })
const result = uglify.minify(rendered)
if (result.code) {
fs.writeFileSync(output, result.code)
} else {
throw new Error(`Failed to compile ${output.split('/').pop()}.\n${result.error}\n`)
function parse(value) {
if (value == null) {
return null
}
return value
.split(/[.,]/)
.filter(feature => !['js', 'plausible'].includes(feature))
.sort()
}
const base_variants = ["hash", "outbound-links", "exclusions", "compat", "local", "manual", "file-downloads", "pageview-props", "tagged-events", "revenue"]
const variants = [...g.clone.powerSet(base_variants)].filter(a => a.length > 0).map(a => a.sort());
const compileOptions = {
targets: parse(values.target),
only: positionals && positionals.length > 0 ? positionals.map(parse) : null,
suffix: values.suffix
}
compilefile(relPath('src/plausible.js'), relPath('../priv/tracker/js/plausible.js'))
await compileAll(compileOptions)
variants.map(variant => {
const options = variant.map(variant => variant.replace('-', '_')).reduce((acc, curr) => (acc[curr] = true, acc), {})
compilefile(relPath('src/plausible.js'), relPath(`../priv/tracker/js/plausible.${variant.join('.')}.js`), options)
})
if (values.watch) {
console.log('Watching src/ directory for changes...')
chokidar.watch('./src').on('change', async (event, path) => {
if (path) {
console.log(`\nFile changed: ${path}`)
console.log('Recompiling...')
await compileAll(compileOptions)
console.log('Done. Watching for changes...')
}
})
}

View File

@ -1,14 +1,17 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const LAST_HASH_FILEPATH = path.join(__dirname, './last-hash.txt')
// Re-compilation is only required if any of these files have been changed.
// Re-compilation is only required if any of these files have been changed.
const COMPILE_DEPENDENCIES = [
path.join(__dirname, './index.js'),
path.join(__dirname, '../compile.js'),
path.join(__dirname, '../src/plausible.js'),
path.join(__dirname, '../src/customEvents.js')
path.join(__dirname, '../src/plausible.js')
]
function currentHash() {
@ -39,7 +42,7 @@ function lastHash() {
* will be updated. Compilation can be skipped if the hash hasn't changed since
* the last execution.
*/
exports.canSkipCompile = function() {
export function canSkipCompile() {
const current = currentHash()
const last = lastHash()
@ -49,4 +52,4 @@ exports.canSkipCompile = function() {
fs.writeFileSync(LAST_HASH_FILEPATH, current)
return false
}
}
}

View File

@ -0,0 +1,24 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import g from 'generatorics'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
function idToGlobal(id) {
return `COMPILE_${id.replace('-', '_').toUpperCase()}`
}
const LEGACY_VARIANT_NAMES = ["hash", "outbound-links", "exclusions", "compat", "local", "manual", "file-downloads", "pageview-props", "tagged-events", "revenue"]
let legacyVariants = [...g.clone.powerSet(LEGACY_VARIANT_NAMES)]
.map(a => a.sort())
.map((variant) => ({
name: variant.length > 0 ? `plausible.${variant.join('.')}.js` : 'plausible.js',
compileIds: variant,
globals: Object.fromEntries(variant.map(id => [idToGlobal(id), true]))
}))
const variantsFile = path.join(__dirname, 'variants.json')
const existingData = JSON.parse(fs.readFileSync(variantsFile, 'utf8'))
fs.writeFileSync(variantsFile, JSON.stringify({ ...existingData, legacyVariants }, null, 2) + "\n")

119
tracker/compiler/index.js Normal file
View File

@ -0,0 +1,119 @@
import uglify from 'uglify-js'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import variantsFile from './variants.json' with { type: 'json' }
import { canSkipCompile } from './can-skip-compile.js'
import packageJson from '../package.json' with { type: 'json' }
import progress from 'cli-progress'
import { spawn, Worker, Pool } from "threads"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DEFAULT_GLOBALS = {
COMPILE_HASH: false,
COMPILE_OUTBOUND_LINKS: false,
COMPILE_EXCLUSIONS: false,
COMPILE_COMPAT: false,
COMPILE_LOCAL: false,
COMPILE_MANUAL: false,
COMPILE_FILE_DOWNLOADS: false,
COMPILE_PAGEVIEW_PROPS: false,
COMPILE_TAGGED_EVENTS: false,
COMPILE_REVENUE: false,
COMPILE_TRACKER_SCRIPT_VERSION: packageJson.tracker_script_version
}
export async function compileAll(options = {}) {
if (process.env.NODE_ENV === 'dev' && canSkipCompile()) {
console.info('COMPILATION SKIPPED: No changes detected in tracker dependencies')
return
}
const variants = getVariantsToCompile(options)
const baseCode = getCode()
const startTime = Date.now();
console.log(`Starting compilation of ${variants.length} variants...`)
const bar = new progress.SingleBar({ clearOnComplete: true }, progress.Presets.shades_classic)
bar.start(variants.length, 0)
const workerPool = Pool(() => spawn(new Worker('./worker-thread.js')))
variants.forEach(variant => {
workerPool.queue(async (worker) => {
await worker.compileFile(variant, { ...options, baseCode })
bar.increment()
})
})
await workerPool.completed()
await workerPool.terminate()
bar.stop()
console.log(`Completed compilation of ${variants.length} variants in ${((Date.now() - startTime) / 1000).toFixed(2)}s`);
}
export function compileFile(variant, options) {
const baseCode = options.baseCode || getCode()
const globals = { ...DEFAULT_GLOBALS, ...variant.globals }
const code = minify(baseCode, globals)
if (options.returnCode) {
return code
} else {
fs.writeFileSync(relPath(`../../priv/tracker/js/${variant.name}${options.suffix || ""}`), code)
}
}
function getVariantsToCompile(options) {
let targetVariants = variantsFile.legacyVariants.concat(variantsFile.manualVariants)
if (options.targets !== null) {
targetVariants = targetVariants.filter(variant =>
options.targets.every(target => variant.compileIds.includes(target))
)
}
if (options.only !== null) {
targetVariants = targetVariants.filter(variant =>
options.only.some(targetCompileIds => equalLists(variant.compileIds, targetCompileIds))
)
}
return targetVariants
}
function getCode() {
// Wrap the code in an instantly evaluating function
return `(function(){${fs.readFileSync(relPath('../src/plausible.js')).toString()}})()`
}
function minify(baseCode, globals) {
const result = uglify.minify(baseCode, {
compress: {
global_defs: globals
}
})
if (result.code) {
return result.code
} else {
throw result.error
}
}
function equalLists(a, b) {
if (a.length != b.length) {
return false
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false
}
}
return true
}
function relPath(segment) {
return path.join(__dirname, segment)
}

17413
tracker/compiler/variants.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
import { compileFile } from './index.js'
import { expose } from "threads/worker"
expose({ compileFile })

View File

@ -6,16 +6,18 @@
"": {
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
"generatorics": "^1.1.0",
"handlebars": "^4.7.8",
"uglify-js": "^3.19.3"
},
"devDependencies": {
"@playwright/test": "^1.48.1",
"@playwright/test": "^1.49.1",
"@types/node": "^22.13.4",
"cli-progress": "^3.12.0",
"eslint": "^9.20.1",
"eslint-plugin-playwright": "^2.2.0",
"express": "^4.21.2"
"express": "^4.21.2",
"threads": "^1.7.0"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@ -205,12 +207,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.48.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.1.tgz",
"integrity": "sha512-s9RtWoxkOLmRJdw3oFvhFbs9OJS0BzrLUc8Hf6l2UdCNd1rqeEyD4BhCJkvzeEoD1FsK4mirsWwGerhVmYKtZg==",
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.48.1"
"playwright": "1.49.1"
},
"bin": {
"playwright": "cli.js"
@ -290,6 +293,16 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@ -435,6 +448,34 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/cli-progress": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
"integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"string-width": "^4.2.3"
},
"engines": {
"node": ">=4"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -571,6 +612,13 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"dev": true
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@ -745,6 +793,17 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esm": {
"version": "3.2.25",
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
"integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=6"
}
},
"node_modules/espree": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
@ -1008,6 +1067,7 @@
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
@ -1109,26 +1169,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.2",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -1248,6 +1288,16 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@ -1260,6 +1310,19 @@
"node": ">=0.10.0"
}
},
"node_modules/is-observable": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-observable/-/is-observable-2.1.0.tgz",
"integrity": "sha512-DailKdLb0WU+xX8K5w7VsJhapwHLZ9jjmazqCJq4X12CTgqq73TKnbRcnSLuXYPOoLQgV5IrD7ePiX/h1vnkBw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -1420,14 +1483,6 @@
"node": "*"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -1449,11 +1504,6 @@
"node": ">= 0.6"
}
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -1466,6 +1516,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/observable-fns": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/observable-fns/-/observable-fns-0.6.1.tgz",
"integrity": "sha512-9gRK4+sRWzeN6AOewNBTLXir7Zl/i3GB6Yl26gK4flxz8BXVpD3kt8amREmWNb0mxYOGDotvE5a4N+PtGGKdkg==",
"dev": true,
"license": "MIT"
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -1495,11 +1552,27 @@
"node": ">= 0.8.0"
}
},
"node_modules/p-limit": {
"node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate/node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
},
@ -1510,14 +1583,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"node_modules/p-locate/node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"dependencies": {
"p-limit": "^3.0.2"
},
"license": "MIT",
"engines": {
"node": ">=10"
},
@ -1571,12 +1642,13 @@
"dev": true
},
"node_modules/playwright": {
"version": "1.48.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.1.tgz",
"integrity": "sha512-j8CiHW/V6HxmbntOfyB4+T/uk08tBy6ph0MpBXwuoofkSnLmlfdYNNkFTYD6ofzzlSqLA1fwH4vwvVFvJgLN0w==",
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.48.1"
"playwright-core": "1.49.1"
},
"bin": {
"playwright": "cli.js"
@ -1589,10 +1661,11 @@
}
},
"node_modules/playwright-core": {
"version": "1.48.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.1.tgz",
"integrity": "sha512-Yw/t4VAFX/bBr1OzwCuOMZkY1Cnb4z/doAFSwf4huqAGWmf9eMNjmK7NiOljCdLmxeRYcGPPmcDgU0zOlzP0YA==",
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
@ -1670,6 +1743,19 @@
"node": ">= 0.8"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -1867,14 +1953,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@ -1884,6 +1962,34 @@
"node": ">= 0.8"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@ -1908,6 +2014,36 @@
"node": ">=8"
}
},
"node_modules/threads": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/threads/-/threads-1.7.0.tgz",
"integrity": "sha512-Mx5NBSHX3sQYR6iI9VYbgHKBLisyB+xROCBGjjWm1O9wb9vfLxdaGtmT/KCjUqMsSNW6nERzCW3T6H43LqjDZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"callsites": "^3.1.0",
"debug": "^4.2.0",
"is-observable": "^2.1.0",
"observable-fns": "^0.6.1"
},
"funding": {
"url": "https://github.com/andywer/threads.js?sponsor=1"
},
"optionalDependencies": {
"tiny-worker": ">= 2"
}
},
"node_modules/tiny-worker": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tiny-worker/-/tiny-worker-2.3.0.tgz",
"integrity": "sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"esm": "^3.2.25"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@ -2021,23 +2157,6 @@
"engines": {
"node": ">= 8"
}
},
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}

View File

@ -1,23 +1,26 @@
{
"tracker_script_version": 6,
"tracker_script_version": 7,
"type": "module",
"scripts": {
"deploy": "node compile.js",
"test": "npm run deploy && npx playwright test",
"test:local": "NODE_ENV=dev npm run deploy && npx playwright test",
"test": "npx playwright test",
"test:local": "npx playwright test",
"report-sizes": "node report-sizes.js",
"start": "node test/support/server.js"
},
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
"generatorics": "^1.1.0",
"handlebars": "^4.7.8",
"uglify-js": "^3.19.3"
},
"devDependencies": {
"express": "^4.21.2",
"@playwright/test": "^1.48.1",
"@playwright/test": "^1.49.1",
"@types/node": "^22.13.4",
"cli-progress": "^3.12.0",
"eslint": "^9.20.1",
"eslint-plugin-playwright": "^2.2.0"
"eslint-plugin-playwright": "^2.2.0",
"express": "^4.21.2",
"threads": "^1.7.0"
}
}

View File

@ -1,10 +1,10 @@
// @ts-check
const { defineConfig, devices } = require('@playwright/test');
import { defineConfig, devices } from '@playwright/test'
/**
* @see https://playwright.dev/docs/test-configuration
*/
module.exports = defineConfig({
export default defineConfig({
testDir: './test',
timeout: 60 * 1000,
fullyParallel: true,
@ -37,4 +37,3 @@ module.exports = defineConfig({
reuseExistingServer: !process.env.CI
},
});

View File

@ -1,8 +1,8 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
import fs from 'fs'
import path from 'path'
import { execSync } from 'child_process'
const PrivTrackerDir = '../priv/tracker/js/';
const PrivTrackerDir = '../priv/tracker/js/'
const toReport = [
'plausible.js',

View File

@ -1,230 +0,0 @@
function getLinkEl(link) {
while (link && (typeof link.tagName === 'undefined' || !isLink(link) || !link.href)) {
link = link.parentNode
}
return link
}
function isLink(element) {
return element && element.tagName && element.tagName.toLowerCase() === 'a'
}
function shouldFollowLink(event, link) {
// If default has been prevented by an external script, Plausible should not intercept navigation.
if (event.defaultPrevented) { return false }
var targetsCurrentWindow = !link.target || link.target.match(/^_(self|parent|top)$/i)
var isRegularClick = !(event.ctrlKey || event.metaKey || event.shiftKey) && event.type === 'click'
return targetsCurrentWindow && isRegularClick
}
var MIDDLE_MOUSE_BUTTON = 1
function handleLinkClickEvent(event) {
if (event.type === 'auxclick' && event.button !== MIDDLE_MOUSE_BUTTON) { return }
var link = getLinkEl(event.target)
var hrefWithoutQuery = link && link.href && link.href.split('?')[0]
{{#if tagged_events}}
if (isElementOrParentTagged(link, 0)) {
// Return to prevent sending multiple events with the same action.
// Clicks on tagged links are handled by another function.
return
}
{{/if}}
{{#if outbound_links}}
if (isOutboundLink(link)) {
return sendLinkClickEvent(event, link, { name: 'Outbound Link: Click', props: { url: link.href } })
}
{{/if}}
{{#if file_downloads}}
if (isDownloadToTrack(hrefWithoutQuery)) {
return sendLinkClickEvent(event, link, { name: 'File Download', props: { url: hrefWithoutQuery } })
}
{{/if}}
}
function sendLinkClickEvent(event, link, eventAttrs) {
var followedLink = false
function followLink() {
if (!followedLink) {
followedLink = true
window.location = link.href
}
}
if (shouldFollowLink(event, link)) {
var attrs = { props: eventAttrs.props, callback: followLink }
{{#if revenue}}
attrs.revenue = eventAttrs.revenue
{{/if}}
plausible(eventAttrs.name, attrs)
setTimeout(followLink, 5000)
event.preventDefault()
} else {
var attrs = { props: eventAttrs.props }
{{#if revenue}}
attrs.revenue = eventAttrs.revenue
{{/if}}
plausible(eventAttrs.name, attrs)
}
}
document.addEventListener('click', handleLinkClickEvent)
document.addEventListener('auxclick', handleLinkClickEvent)
{{#if outbound_links}}
function isOutboundLink(link) {
return link && link.href && link.host && link.host !== location.host
}
{{/if}}
{{#if file_downloads}}
var defaultFileTypes = ['pdf', 'xlsx', 'docx', 'txt', 'rtf', 'csv', 'exe', 'key', 'pps', 'ppt', 'pptx', '7z', 'pkg', 'rar', 'gz', 'zip', 'avi', 'mov', 'mp4', 'mpeg', 'wmv', 'midi', 'mp3', 'wav', 'wma', 'dmg']
var fileTypesAttr = scriptEl.getAttribute('file-types')
var addFileTypesAttr = scriptEl.getAttribute('add-file-types')
var fileTypesToTrack = (fileTypesAttr && fileTypesAttr.split(",")) || (addFileTypesAttr && addFileTypesAttr.split(",").concat(defaultFileTypes)) || defaultFileTypes;
function isDownloadToTrack(url) {
if (!url) { return false }
var fileType = url.split('.').pop();
return fileTypesToTrack.some(function (fileTypeToTrack) {
return fileTypeToTrack === fileType
})
}
{{/if}}
{{#if tagged_events}}
// Finds event attributes by iterating over the given element's (or its
// parent's) classList. Returns an object with `name` and `props` keys.
function getTaggedEventAttributes(htmlElement) {
var taggedElement = isTagged(htmlElement) ? htmlElement : htmlElement && htmlElement.parentNode
var eventAttrs = { name: null, props: {} }
{{#if revenue}}
eventAttrs.revenue = {}
{{/if}}
var classList = taggedElement && taggedElement.classList
if (!classList) { return eventAttrs }
for (var i = 0; i < classList.length; i++) {
var className = classList.item(i)
var matchList = className.match(/plausible-event-(.+)(=|--)(.+)/)
if (matchList) {
var key = matchList[1]
var value = matchList[3].replace(/\+/g, ' ')
if (key.toLowerCase() == 'name') {
eventAttrs.name = value
} else {
eventAttrs.props[key] = value
}
}
{{#if revenue}}
var revenueMatchList = className.match(/plausible-revenue-(.+)(=|--)(.+)/)
if (revenueMatchList) {
var key = revenueMatchList[1]
var value = revenueMatchList[3]
eventAttrs.revenue[key] = value
}
{{/if}}
}
return eventAttrs
}
function handleTaggedFormSubmitEvent(event) {
var form = event.target
var eventAttrs = getTaggedEventAttributes(form)
if (!eventAttrs.name) { return }
event.preventDefault()
var formSubmitted = false
function submitForm() {
if (!formSubmitted) {
formSubmitted = true
form.submit()
}
}
setTimeout(submitForm, 5000)
var attrs = { props: eventAttrs.props, callback: submitForm }
{{#if revenue}}
attrs.revenue = eventAttrs.revenue
{{/if}}
plausible(eventAttrs.name, attrs)
}
function isForm(element) {
return element && element.tagName && element.tagName.toLowerCase() === 'form'
}
var PARENTS_TO_SEARCH_LIMIT = 3
function handleTaggedElementClickEvent(event) {
if (event.type === 'auxclick' && event.button !== MIDDLE_MOUSE_BUTTON) { return }
var clicked = event.target
var clickedLink
var taggedElement
// Iterate over parents to find the tagged element. Also search for
// a link element to call for different tracking behavior if found.
for (var i = 0; i <= PARENTS_TO_SEARCH_LIMIT; i++) {
if (!clicked) { break }
// Clicks inside forms are not tracked. Only form submits are.
if (isForm(clicked)) { return }
if (isLink(clicked)) { clickedLink = clicked }
if (isTagged(clicked)) { taggedElement = clicked }
clicked = clicked.parentNode
}
if (taggedElement) {
var eventAttrs = getTaggedEventAttributes(taggedElement)
if (clickedLink) {
// if the clicked tagged element is a link, we attach the `url` property
// automatically for user convenience
eventAttrs.props.url = clickedLink.href
sendLinkClickEvent(event, clickedLink, eventAttrs)
} else {
var attrs = {}
attrs.props = eventAttrs.props
{{#if revenue}}
attrs.revenue = eventAttrs.revenue
{{/if}}
plausible(eventAttrs.name, attrs)
}
}
}
function isTagged(element) {
var classList = element && element.classList
if (classList) {
for (var i = 0; i < classList.length; i++) {
if (classList.item(i).match(/plausible-event-name(=|--)(.+)/)) { return true }
}
}
return false
}
function isElementOrParentTagged(element, parentsChecked) {
if (!element || parentsChecked > PARENTS_TO_SEARCH_LIMIT) { return false }
if (isTagged(element)) { return true }
return isElementOrParentTagged(element.parentNode, parentsChecked + 1)
}
document.addEventListener('submit', handleTaggedFormSubmitEvent)
document.addEventListener('click', handleTaggedElementClickEvent)
document.addEventListener('auxclick', handleTaggedElementClickEvent)
{{/if}}

View File

@ -1,15 +1,14 @@
(function(){
'use strict';
var location = window.location
var document = window.document
{{#if compat}}
if (COMPILE_COMPAT) {
var scriptEl = document.getElementById('plausible');
{{else}}
} else {
var scriptEl = document.currentScript;
{{/if}}
var endpoint = scriptEl.getAttribute('data-api') || defaultEndpoint(scriptEl)
}
var endpoint = scriptEl.getAttribute('data-api') || defaultEndpoint()
var dataDomain = scriptEl.getAttribute('data-domain')
function onIgnoredEvent(eventName, reason, options) {
@ -21,15 +20,15 @@
}
}
function defaultEndpoint(el) {
{{#if compat}}
var pathArray = el.src.split( '/' );
function defaultEndpoint() {
if (COMPILE_COMPAT) {
var pathArray = scriptEl.src.split( '/' );
var protocol = pathArray[0];
var host = pathArray[2];
return protocol + '//' + host + '/api/event';
{{else}}
return new URL(el.src).origin + '/api/event'
{{/if}}
} else {
return new URL(scriptEl.src).origin + '/api/event'
}
}
var currentEngagementIgnored
@ -127,16 +126,16 @@
u: currentEngagementURL,
p: currentEngagementProps,
e: engagementTime,
v: {{TRACKER_SCRIPT_VERSION}}
v: COMPILE_TRACKER_SCRIPT_VERSION
}
// Reset current engagement time metrics. They will restart upon when page becomes visible or the next SPA pageview
runningEngagementStart = null
currentEngagementTime = 0
{{#if hash}}
if (COMPILE_HASH) {
payload.h = 1
{{/if}}
}
sendRequest(endpoint, payload)
}
@ -176,14 +175,14 @@
maxScrollDepthPx = getCurrentScrollDepthPx()
}
{{#unless local}}
if (!COMPILE_LOCAL) {
if (/^localhost$|^127(\.[0-9]+){0,2}\.[0-9]+$|^\[::1?\]$/.test(location.hostname) || location.protocol === 'file:') {
return onIgnoredEvent(eventName, 'localhost', options)
}
if ((window._phantom || window.__nightmare || window.navigator.webdriver || window.Cypress) && !window.__plausible) {
return onIgnoredEvent(eventName, null, options)
}
{{/unless}}
}
try {
if (window.localStorage.plausible_ignore === 'true') {
return onIgnoredEvent(eventName, 'localStorage flag', options)
@ -191,7 +190,7 @@
} catch (e) {
}
{{#if exclusions}}
if (COMPILE_EXCLUSIONS) {
var dataIncludeAttr = scriptEl && scriptEl.getAttribute('data-include')
var dataExcludeAttr = scriptEl && scriptEl.getAttribute('data-exclude')
@ -205,25 +204,25 @@
function pathMatches(wildcardPath) {
var actualPath = location.pathname
{{#if hash}}
if (COMPILE_HASH) {
actualPath += location.hash
{{/if}}
}
return actualPath.match(new RegExp('^' + wildcardPath.trim().replace(/\*\*/g, '.*').replace(/([^\.])\*/g, '$1[^\\s\/]*') + '\/?$'))
}
{{/if}}
}
var payload = {}
payload.n = eventName
payload.v = {{TRACKER_SCRIPT_VERSION}}
payload.v = COMPILE_TRACKER_SCRIPT_VERSION
{{#if manual}}
if (COMPILE_MANUAL) {
var customURL = options && options.u
payload.u = customURL ? customURL : location.href
{{else}}
} else {
payload.u = location.href
{{/if}}
}
payload.d = dataDomain
payload.r = document.referrer || null
@ -236,13 +235,13 @@
if (options && options.interactive === false) {
payload.i = false
}
{{#if revenue}}
if (COMPILE_REVENUE) {
if (options && options.revenue) {
payload.$ = options.revenue
}
{{/if}}
}
{{#if pageview_props}}
if (COMPILE_PAGEVIEW_PROPS) {
var propAttributes = scriptEl.getAttributeNames().filter(function (name) {
return name.substring(0, 6) === 'event-'
})
@ -256,11 +255,11 @@
})
payload.p = props
{{/if}}
}
{{#if hash}}
if (COMPILE_HASH) {
payload.h = 1
{{/if}}
}
if (isPageview) {
currentEngagementIgnored = false
@ -276,7 +275,7 @@
}
function sendRequest(endpoint, payload, options) {
{{#if compat}}
if (COMPILE_COMPAT) {
var request = new XMLHttpRequest();
request.open('POST', endpoint, true);
request.setRequestHeader('Content-Type', 'text/plain');
@ -288,7 +287,7 @@
options && options.callback && options.callback({status: request.status})
}
}
{{else}}
} else {
if (window.fetch) {
fetch(endpoint, {
method: 'POST',
@ -301,7 +300,7 @@
options && options.callback && options.callback({status: response.status})
}).catch(function() {})
}
{{/if}}
}
}
var queue = (window.plausible && window.plausible.q) || []
@ -310,13 +309,13 @@
trigger.apply(this, queue[i])
}
{{#unless manual}}
if (!COMPILE_MANUAL) {
var lastPage;
function page(isSPANavigation) {
{{#unless hash}}
if (!COMPILE_HASH) {
if (isSPANavigation && lastPage === location.pathname) return;
{{/unless}}
}
lastPage = location.pathname
trigger('pageview')
@ -324,9 +323,9 @@
var onSPANavigation = function() {page(true)}
{{#if hash}}
if (COMPILE_HASH) {
window.addEventListener('hashchange', onSPANavigation)
{{else}}
} else {
var his = window.history
if (his.pushState) {
var originalPushState = his['pushState']
@ -336,7 +335,7 @@
}
window.addEventListener('popstate', onSPANavigation)
}
{{/if}}
}
function handleVisibilityChange() {
if (!lastPage && document.visibilityState === 'visible') {
@ -356,9 +355,237 @@
page();
}
})
{{/unless}}
}
{{#if (any outbound_links file_downloads tagged_events)}}
{{> customEvents}}
{{/if}}
})();
if (COMPILE_OUTBOUND_LINKS || COMPILE_FILE_DOWNLOADS || COMPILE_TAGGED_EVENTS) {
function getLinkEl(link) {
while (link && (typeof link.tagName === 'undefined' || !isLink(link) || !link.href)) {
link = link.parentNode
}
return link
}
function isLink(element) {
return element && element.tagName && element.tagName.toLowerCase() === 'a'
}
function shouldFollowLink(event, link) {
// If default has been prevented by an external script, Plausible should not intercept navigation.
if (event.defaultPrevented) { return false }
var targetsCurrentWindow = !link.target || link.target.match(/^_(self|parent|top)$/i)
var isRegularClick = !(event.ctrlKey || event.metaKey || event.shiftKey) && event.type === 'click'
return targetsCurrentWindow && isRegularClick
}
var MIDDLE_MOUSE_BUTTON = 1
function handleLinkClickEvent(event) {
if (event.type === 'auxclick' && event.button !== MIDDLE_MOUSE_BUTTON) { return }
var link = getLinkEl(event.target)
var hrefWithoutQuery = link && link.href && link.href.split('?')[0]
if (COMPILE_TAGGED_EVENTS) {
if (isElementOrParentTagged(link, 0)) {
// Return to prevent sending multiple events with the same action.
// Clicks on tagged links are handled by another function.
return
}
}
if (COMPILE_OUTBOUND_LINKS) {
if (isOutboundLink(link)) {
return sendLinkClickEvent(event, link, { name: 'Outbound Link: Click', props: { url: link.href } })
}
}
if (COMPILE_FILE_DOWNLOADS) {
if (isDownloadToTrack(hrefWithoutQuery)) {
return sendLinkClickEvent(event, link, { name: 'File Download', props: { url: hrefWithoutQuery } })
}
}
}
function sendLinkClickEvent(event, link, eventAttrs) {
var followedLink = false
function followLink() {
if (!followedLink) {
followedLink = true
window.location = link.href
}
}
if (shouldFollowLink(event, link)) {
var attrs = { props: eventAttrs.props, callback: followLink }
if (COMPILE_REVENUE) {
attrs.revenue = eventAttrs.revenue
}
plausible(eventAttrs.name, attrs)
setTimeout(followLink, 5000)
event.preventDefault()
} else {
var attrs = { props: eventAttrs.props }
if (COMPILE_REVENUE) {
attrs.revenue = eventAttrs.revenue
}
plausible(eventAttrs.name, attrs)
}
}
document.addEventListener('click', handleLinkClickEvent)
document.addEventListener('auxclick', handleLinkClickEvent)
if (COMPILE_OUTBOUND_LINKS) {
function isOutboundLink(link) {
return link && link.href && link.host && link.host !== location.host
}
}
if (COMPILE_FILE_DOWNLOADS) {
var defaultFileTypes = ['pdf', 'xlsx', 'docx', 'txt', 'rtf', 'csv', 'exe', 'key', 'pps', 'ppt', 'pptx', '7z', 'pkg', 'rar', 'gz', 'zip', 'avi', 'mov', 'mp4', 'mpeg', 'wmv', 'midi', 'mp3', 'wav', 'wma', 'dmg']
var fileTypesAttr = scriptEl.getAttribute('file-types')
var addFileTypesAttr = scriptEl.getAttribute('add-file-types')
var fileTypesToTrack = (fileTypesAttr && fileTypesAttr.split(",")) || (addFileTypesAttr && addFileTypesAttr.split(",").concat(defaultFileTypes)) || defaultFileTypes;
function isDownloadToTrack(url) {
if (!url) { return false }
var fileType = url.split('.').pop();
return fileTypesToTrack.some(function (fileTypeToTrack) {
return fileTypeToTrack === fileType
})
}
}
if (COMPILE_TAGGED_EVENTS) {
// Finds event attributes by iterating over the given element's (or its
// parent's) classList. Returns an object with `name` and `props` keys.
function getTaggedEventAttributes(htmlElement) {
var taggedElement = isTagged(htmlElement) ? htmlElement : htmlElement && htmlElement.parentNode
var eventAttrs = { name: null, props: {} }
if (COMPILE_REVENUE) {
eventAttrs.revenue = {}
}
var classList = taggedElement && taggedElement.classList
if (!classList) { return eventAttrs }
for (var i = 0; i < classList.length; i++) {
var className = classList.item(i)
var matchList = className.match(/plausible-event-(.+)(=|--)(.+)/)
if (matchList) {
var key = matchList[1]
var value = matchList[3].replace(/\+/g, ' ')
if (key.toLowerCase() == 'name') {
eventAttrs.name = value
} else {
eventAttrs.props[key] = value
}
}
if (COMPILE_REVENUE) {
var revenueMatchList = className.match(/plausible-revenue-(.+)(=|--)(.+)/)
if (revenueMatchList) {
var key = revenueMatchList[1]
var value = revenueMatchList[3]
eventAttrs.revenue[key] = value
}
}
}
return eventAttrs
}
function handleTaggedFormSubmitEvent(event) {
var form = event.target
var eventAttrs = getTaggedEventAttributes(form)
if (!eventAttrs.name) { return }
event.preventDefault()
var formSubmitted = false
function submitForm() {
if (!formSubmitted) {
formSubmitted = true
form.submit()
}
}
setTimeout(submitForm, 5000)
var attrs = { props: eventAttrs.props, callback: submitForm }
if (COMPILE_REVENUE) {
attrs.revenue = eventAttrs.revenue
}
plausible(eventAttrs.name, attrs)
}
function isForm(element) {
return element && element.tagName && element.tagName.toLowerCase() === 'form'
}
var PARENTS_TO_SEARCH_LIMIT = 3
function handleTaggedElementClickEvent(event) {
if (event.type === 'auxclick' && event.button !== MIDDLE_MOUSE_BUTTON) { return }
var clicked = event.target
var clickedLink
var taggedElement
// Iterate over parents to find the tagged element. Also search for
// a link element to call for different tracking behavior if found.
for (var i = 0; i <= PARENTS_TO_SEARCH_LIMIT; i++) {
if (!clicked) { break }
// Clicks inside forms are not tracked. Only form submits are.
if (isForm(clicked)) { return }
if (isLink(clicked)) { clickedLink = clicked }
if (isTagged(clicked)) { taggedElement = clicked }
clicked = clicked.parentNode
}
if (taggedElement) {
var eventAttrs = getTaggedEventAttributes(taggedElement)
if (clickedLink) {
// if the clicked tagged element is a link, we attach the `url` property
// automatically for user convenience
eventAttrs.props.url = clickedLink.href
sendLinkClickEvent(event, clickedLink, eventAttrs)
} else {
var attrs = {}
attrs.props = eventAttrs.props
if (COMPILE_REVENUE) {
attrs.revenue = eventAttrs.revenue
}
plausible(eventAttrs.name, attrs)
}
}
}
function isTagged(element) {
var classList = element && element.classList
if (classList) {
for (var i = 0; i < classList.length; i++) {
if (classList.item(i).match(/plausible-event-name(=|--)(.+)/)) { return true }
}
}
return false
}
function isElementOrParentTagged(element, parentsChecked) {
if (!element || parentsChecked > PARENTS_TO_SEARCH_LIMIT) { return false }
if (isTagged(element)) { return true }
return isElementOrParentTagged(element.parentNode, parentsChecked + 1)
}
document.addEventListener('submit', handleTaggedFormSubmitEvent)
document.addEventListener('click', handleTaggedElementClickEvent)
document.addEventListener('auxclick', handleTaggedElementClickEvent)
}
}

View File

@ -1,6 +1,6 @@
const { expectPlausibleInAction } = require('./support/test-utils')
const { test } = require('@playwright/test')
const { LOCAL_SERVER_ADDR } = require('./support/server')
import { expectPlausibleInAction } from './support/test-utils'
import { test } from '@playwright/test'
import { LOCAL_SERVER_ADDR } from './support/server'
test.describe('script.file-downloads.outbound-links.tagged-events.js', () => {
test('sends only outbound link event when clicked link is both download and outbound', async ({ page }) => {

View File

@ -1,8 +1,7 @@
const { expect } = require("@playwright/test")
const { expectPlausibleInAction, hideAndShowCurrentTab, focus, blur, blurAndFocusPage } = require('./support/test-utils')
const { test } = require('@playwright/test')
const { LOCAL_SERVER_ADDR } = require('./support/server')
const { tracker_script_version } = require('../package.json')
import { expect } from "@playwright/test"
import { expectPlausibleInAction, hideAndShowCurrentTab, focus, blur, blurAndFocusPage, tracker_script_version } from './support/test-utils'
import { test } from '@playwright/test'
import { LOCAL_SERVER_ADDR } from './support/server'
test.describe('engagement events', () => {
test('sends an engagement event with time measurement when navigating to the next page', async ({ page }) => {
@ -205,6 +204,7 @@ test.describe('engagement events', () => {
await expectPlausibleInAction(page, {
action: async () => {
await page.click('#to-pageleave-pageview-props')
await page.waitForTimeout(500)
await page.click('#back-button-trigger')
},
expectedRequests: [

View File

@ -1,6 +1,6 @@
const { mockRequest, mockManyRequests, metaKey, expectPlausibleInAction } = require('./support/test-utils')
const { expect, test } = require('@playwright/test')
const { LOCAL_SERVER_ADDR } = require('./support/server')
import { mockRequest, mockManyRequests, metaKey, expectPlausibleInAction } from './support/test-utils'
import { expect, test } from '@playwright/test'
import { LOCAL_SERVER_ADDR } from './support/server'
test.describe('file-downloads extension', () => {
test('sends event and does not start download when link opens in new tab', async ({ page }) => {

View File

@ -1,5 +1,5 @@
const { mockRequest } = require('./support/test-utils')
const { expect, test } = require('@playwright/test')
import { mockRequest } from './support/test-utils'
import { expect, test } from '@playwright/test'
test.describe('combination of hash and exclusions script extensions', () => {
test('excludes by hash part of the URL', async ({ page }) => {

View File

@ -1,6 +1,6 @@
const { expectPlausibleInAction } = require('./support/test-utils')
const { test } = require('@playwright/test')
const { LOCAL_SERVER_ADDR } = require('./support/server')
import { expectPlausibleInAction } from './support/test-utils'
import { test } from '@playwright/test'
import { LOCAL_SERVER_ADDR } from './support/server'
test.describe('manual extension', () => {
test('can trigger custom events with and without a custom URL if pageview was sent with the default URL', async ({ page }) => {

View File

@ -1,5 +1,5 @@
const { mockRequest, metaKey, expectPlausibleInAction } = require('./support/test-utils')
const { expect, test } = require('@playwright/test')
import { mockRequest, metaKey, expectPlausibleInAction } from './support/test-utils'
import { expect, test } from '@playwright/test'
test.describe('outbound-links extension', () => {
test('sends event and does not navigate when link opens in new tab', async ({ page }) => {
@ -7,7 +7,7 @@ test.describe('outbound-links extension', () => {
const outboundURL = await page.locator('#link').getAttribute('href')
const navigationRequestMock = mockRequest(page, outboundURL)
await expectPlausibleInAction(page, {
action: () => page.click('#link', { modifiers: [metaKey()] }),
expectedRequests: [{n: 'Outbound Link: Click', p: { url: outboundURL }}]

View File

@ -1,6 +1,5 @@
const { mockRequest } = require('./support/test-utils')
const { expect, test } = require('@playwright/test')
const { tracker_script_version } = require('../package.json')
import { mockRequest, tracker_script_version } from './support/test-utils'
import { expect, test } from '@playwright/test'
test.describe('Basic installation', () => {
test('Sends pageview automatically', async ({ page }) => {

View File

@ -1,5 +1,5 @@
const { expectPlausibleInAction } = require('./support/test-utils')
const { test } = require('@playwright/test')
import { expectPlausibleInAction } from './support/test-utils'
import { test } from '@playwright/test'
test.describe('with revenue script extension', () => {
test('sends revenue currency and amount in manual mode', async ({ page }) => {

View File

@ -1,6 +1,6 @@
const { expectPlausibleInAction, hideCurrentTab, hideAndShowCurrentTab } = require('./support/test-utils')
const { test } = require('@playwright/test')
const { LOCAL_SERVER_ADDR } = require('./support/server')
import { expectPlausibleInAction, hideCurrentTab, hideAndShowCurrentTab } from './support/test-utils'
import { test } from '@playwright/test'
import { LOCAL_SERVER_ADDR } from './support/server'
test.describe('scroll depth (engagement events)', () => {
test('sends scroll_depth in the pageleave payload when navigating to the next page', async ({ page }) => {

View File

@ -1,15 +1,30 @@
const express = require('express');
import express from 'express'
import path from 'node:path'
import { fileURLToPath } from 'url'
import { compileFile } from '../../compiler/index.js'
import variantsFile from '../../compiler/variants.json' with { type: 'json' }
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const isMainModule = fileURLToPath(import.meta.url) === process.argv[1];
const app = express();
const path = require('node:path');
const LOCAL_SERVER_PORT = 3000
const LOCAL_SERVER_ADDR = `http://localhost:${LOCAL_SERVER_PORT}`
const FIXTURES_PATH = path.join(__dirname, '/../fixtures')
const TRACKERS_PATH = path.join(__dirname, '/../../../priv/tracker')
const VARIANTS = variantsFile.legacyVariants.concat(variantsFile.manualVariants)
exports.runLocalFileServer = function () {
export const LOCAL_SERVER_ADDR = `http://localhost:${LOCAL_SERVER_PORT}`
export function runLocalFileServer() {
app.use(express.static(FIXTURES_PATH));
app.use('/tracker', express.static(TRACKERS_PATH));
app.get('/tracker/js/:name', (req, res) => {
const name = req.params.name
const variant = VARIANTS.find((variant) => variant.name === name)
const code = compileFile(variant, { returnCode: true })
res.send(code)
});
// A test utility - serve an image with an artificial delay
app.get('/img/slow-image', (_req, res) => {
@ -23,8 +38,6 @@ exports.runLocalFileServer = function () {
});
}
if (require.main === module) {
exports.runLocalFileServer()
if (isMainModule) {
runLocalFileServer()
}
exports.LOCAL_SERVER_ADDR = LOCAL_SERVER_ADDR

View File

@ -1,8 +1,11 @@
const { expect, Page } = require("@playwright/test");
import { expect } from "@playwright/test"
import packageJson from '../../package.json' with { type: 'json' }
export const tracker_script_version = packageJson.tracker_script_version
// Mocks an HTTP request call with the given path. Returns a Promise that resolves to the request
// data. If the request is not made, resolves to null after 3 seconds.
const mockRequest = function (page, path) {
export const mockRequest = function (page, path) {
return new Promise((resolve, _reject) => {
const requestTimeoutTimer = setTimeout(() => resolve(null), 3000)
@ -14,9 +17,7 @@ const mockRequest = function (page, path) {
})
}
exports.mockRequest = mockRequest
exports.metaKey = function() {
export const metaKey = function() {
if (process.platform === 'darwin') {
return 'Meta'
} else {
@ -26,7 +27,7 @@ exports.metaKey = function() {
// Mocks a specified number of HTTP requests with given path. Returns a promise that resolves to a
// list of requests as soon as the specified number of requests is made, or 3 seconds has passed.
const mockManyRequests = function({ page, path, numberOfRequests, responseDelay, shouldIgnoreRequest, mockRequestTimeout = 3000 }) {
export const mockManyRequests = function({ page, path, numberOfRequests, responseDelay, shouldIgnoreRequest, mockRequestTimeout = 3000 }) {
return new Promise((resolve, _reject) => {
let requestList = []
const requestTimeoutTimer = setTimeout(() => resolve(requestList), mockRequestTimeout)
@ -48,8 +49,6 @@ const mockManyRequests = function({ page, path, numberOfRequests, responseDelay,
})
}
exports.mockManyRequests = mockManyRequests
/**
* A powerful utility function that makes it easy to assert on the event
* requests that should or should not have been made after doing a page
@ -76,7 +75,7 @@ exports.mockManyRequests = mockManyRequests
* @param {number} [args.responseDelay] - When provided, delays the response from the Plausible
* API by the given number of milliseconds.
*/
exports.expectPlausibleInAction = async function (page, {
export const expectPlausibleInAction = async function (page, {
action,
expectedRequests = [],
refutedRequests = [],
@ -131,11 +130,11 @@ exports.expectPlausibleInAction = async function (page, {
return requestBodies
}
exports.ignoreEngagementRequests = function(requestPostData) {
export const ignoreEngagementRequests = function(requestPostData) {
return requestPostData.n === 'engagement'
}
exports.ignorePageleaveRequests = function(requestPostData) {
export const ignorePageleaveRequests = function(requestPostData) {
return requestPostData.n === 'pageleave'
}
@ -147,11 +146,11 @@ async function toggleTabVisibility(page, hide) {
}, hide)
}
exports.hideCurrentTab = async function(page) {
export const hideCurrentTab = async function(page) {
return toggleTabVisibility(page, true)
}
exports.showCurrentTab = async function(page) {
export const showCurrentTab = async function(page) {
return toggleTabVisibility(page, false)
}
@ -164,28 +163,28 @@ async function setFocus(page, focus) {
}, focus)
}
exports.focus = async function(page) {
export const focus = async function(page) {
return setFocus(page, true)
}
exports.blur = async function(page) {
export const blur = async function(page) {
return setFocus(page, false)
}
exports.hideAndShowCurrentTab = async function(page, options = {}) {
await exports.hideCurrentTab(page)
export const hideAndShowCurrentTab = async function(page, options = {}) {
await hideCurrentTab(page)
if (options.delay > 0) {
await delay(options.delay)
}
await exports.showCurrentTab(page)
await showCurrentTab(page)
}
exports.blurAndFocusPage = async function(page, options = {}) {
await exports.blur(page)
export const blurAndFocusPage = async function(page, options = {}) {
await blur(page)
if (options.delay > 0) {
await delay(options.delay)
}
await exports.focus(page)
await focus(page)
}
function includesSubset(body, subset) {

View File

@ -1,5 +1,5 @@
const { mockRequest, metaKey, expectPlausibleInAction } = require('./support/test-utils')
const { expect, test } = require('@playwright/test')
import { mockRequest, metaKey, expectPlausibleInAction } from './support/test-utils'
import { expect, test } from '@playwright/test'
test.describe('tagged-events extension', () => {
test('tracks a tagged link click with custom props + url prop', async ({ page }) => {