├── .node-version ├── .gitignore ├── src ├── icons │ ├── 128.png │ ├── 48.png │ ├── crown-logo-19-active.png │ ├── crown-logo-38-active.png │ ├── crown-logo-19-inactive.png │ └── crown-logo-38-inactive.png ├── service_worker.js ├── manifest_chrome.json ├── components │ ├── show-meta-tags-component │ │ ├── show-meta-tags-component.css │ │ └── show-meta-tags-component.js │ ├── design-mode-component │ │ ├── design-mode-component.css │ │ └── design-mode-component.js │ ├── highlight-component │ │ ├── highlight-component.css │ │ └── highlight-component.js │ └── content-blocks-component │ │ ├── content-blocks-component.css │ │ └── content-blocks-component.js ├── manifest_firefox.json ├── popup │ ├── ab_tests.js │ ├── extract_path.js │ ├── popup.css │ ├── reset.css │ ├── content_links.js │ ├── environment.js │ ├── external_links.js │ ├── lib │ │ └── mustache.min.js │ └── popup.js ├── service_utils │ ├── ab_bucket_store.js │ ├── icon.js │ └── ab_test_settings.js ├── fetch-page-data.js ├── manifest_base.json └── popup.html ├── docs ├── screenshots.gif └── releasing.md ├── spec ├── javascripts │ ├── fixtures │ │ ├── gem-c-button.html │ │ ├── gem-c-label.html │ │ ├── app-c-back-to-top.html │ │ ├── meta-tags.html │ │ ├── content-blocks.html │ │ └── gem-c-breadcrumbs.html │ ├── show_meta_tags.spec.js │ ├── design_mode_component.spec.js │ ├── ab_tests.spec.js │ ├── environment.spec.js │ ├── extract_path.spec.js │ ├── content_blocks_component.spec.js │ ├── content_links.spec.js │ ├── events │ │ └── ab_bucket_store.spec.js │ ├── highlight_component.spec.js │ └── external_links.spec.js ├── helpers │ ├── helpers.js │ ├── polyfills │ │ └── String.startsWith.js │ └── jasmine-jquery.js └── support │ └── jasmine-browser.json ├── .github ├── dependabot.yml └── workflows │ ├── actionlint.yml │ ├── ci.yml │ └── release.yml ├── utils ├── check-firefox-version.mjs └── check-chrome-version.mjs ├── package.json └── README.md /.node-version: -------------------------------------------------------------------------------- 1 | 20.12.2 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | manifest.json -------------------------------------------------------------------------------- /src/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/govuk-browser-extension/HEAD/src/icons/128.png -------------------------------------------------------------------------------- /src/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/govuk-browser-extension/HEAD/src/icons/48.png -------------------------------------------------------------------------------- /src/service_worker.js: -------------------------------------------------------------------------------- 1 | /* global importScripts */ 2 | 3 | importScripts('./service_utils/icon.js') 4 | -------------------------------------------------------------------------------- /docs/screenshots.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/govuk-browser-extension/HEAD/docs/screenshots.gif -------------------------------------------------------------------------------- /spec/javascripts/fixtures/gem-c-button.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/manifest_chrome.json: -------------------------------------------------------------------------------- 1 | { 2 | "background": { 3 | "service_worker": "service_worker.js" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/icons/crown-logo-19-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/govuk-browser-extension/HEAD/src/icons/crown-logo-19-active.png -------------------------------------------------------------------------------- /src/icons/crown-logo-38-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/govuk-browser-extension/HEAD/src/icons/crown-logo-38-active.png -------------------------------------------------------------------------------- /spec/helpers/helpers.js: -------------------------------------------------------------------------------- 1 | function pluck (array, key) { 2 | return array.map(function (object) { 3 | return object[key] 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /src/icons/crown-logo-19-inactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/govuk-browser-extension/HEAD/src/icons/crown-logo-19-inactive.png -------------------------------------------------------------------------------- /src/icons/crown-logo-38-inactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/govuk-browser-extension/HEAD/src/icons/crown-logo-38-inactive.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | dependency-type: production 6 | schedule: 7 | interval: daily 8 | -------------------------------------------------------------------------------- /spec/javascripts/fixtures/gem-c-label.html: -------------------------------------------------------------------------------- 1 |
Here is some text
4 | 5 |enquiries@companieshouse.gov.uk
6 | 7 |And some more text
8 | 9 |enquiries@companieshouse.gov.uk
10 | -------------------------------------------------------------------------------- /spec/javascripts/fixtures/gem-c-breadcrumbs.html: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /utils/check-firefox-version.mjs: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { existsSync } from 'node:fs'; 3 | 4 | const response = await fetch(`https://addons.mozilla.org/api/v5/addons/search/?guid=${process.env.FIREFOX_EXTENSION_ID}`); 5 | const body = await response.json(); 6 | 7 | const publishedVersion = body.results[0].current_version.version 8 | const currentVersion = process.env.CURRENT_VERSION 9 | 10 | if (publishedVersion === currentVersion) { 11 | console.log(`Currently published version is ${publishedVersion} - nothing to do`) 12 | process.exit(1) 13 | } 14 | 15 | if (!existsSync(`build/govuk-browser-extension-firefox-${currentVersion}.zip`)) { 16 | console.log(`The latest version is ${currentVersion}, but a build has not been uploaded`) 17 | process.exit(1) 18 | } 19 | 20 | -------------------------------------------------------------------------------- /src/components/content-blocks-component/content-blocks-component.css: -------------------------------------------------------------------------------- 1 | .highlight-content-block { 2 | cursor: pointer; 3 | background-color: #ffdd00; 4 | position: relative; 5 | } 6 | 7 | .highlight-content-block:hover:after { 8 | background-color: #ffdd00; 9 | color: #0B0C0C; 10 | content: attr(data-document-type); 11 | font-family: sans-serif; 12 | font-size: 12px; 13 | font-weight: normal; 14 | padding-left: 3px; 15 | padding-bottom: 3px; 16 | position: absolute; 17 | left: 0; 18 | top: 100%; 19 | z-index: 1; 20 | } 21 | 22 | .govuk-chrome-content-blocks-banner { 23 | padding: 20px; 24 | background-color: #f3f2f1; 25 | } 26 | 27 | .govuk-chrome-content-blocks-banner__subhead:first-letter { 28 | text-transform: uppercase; 29 | } 30 | 31 | .govuk-chrome-content-blocks-banner__button { 32 | margin-left: 10px; 33 | } 34 | -------------------------------------------------------------------------------- /utils/check-chrome-version.mjs: -------------------------------------------------------------------------------- 1 | import chromeWebstoreUpload from 'chrome-webstore-upload'; 2 | import { existsSync } from 'node:fs'; 3 | 4 | const store = chromeWebstoreUpload({ 5 | extensionId: process.env.CHROME_EXTENSION_ID, 6 | clientId: process.env.CHROME_CLIENT_ID, 7 | clientSecret: process.env.CHROME_CLIENT_SECRET, 8 | refreshToken: process.env.CHROME_REFRESH_TOKEN, 9 | }); 10 | 11 | const response = await store.get(); 12 | 13 | const publishedVersion = response.crxVersion; 14 | 15 | const currentVersion = process.env.CURRENT_VERSION 16 | 17 | if (publishedVersion === currentVersion) { 18 | console.log(`Currently published version is ${publishedVersion} - nothing to do`) 19 | process.exit(1) 20 | } 21 | 22 | if (!existsSync(`build/govuk-browser-extension-chrome-${currentVersion}.zip`)) { 23 | console.log(`The latest version is ${currentVersion}, but a build has not been uploaded`) 24 | process.exit(1) 25 | } 26 | 27 | 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "chrome-webstore-upload": "^3.2.0", 4 | "jasmine-browser-runner": "^3.0.0", 5 | "jasmine-core": "^5.10.0", 6 | "node-fetch": "^3.3.2", 7 | "standardx": "^7.0.0" 8 | }, 9 | "scripts": { 10 | "lint:js": "standardx 'spec/javascripts/**/*.js' 'src/**/*.js'", 11 | "lint:js:fix": "npm run lint:js -- --fix", 12 | "test": "jasmine-browser-runner runSpecs", 13 | "build": "bash build.sh", 14 | "check:firefox": "node utils/check-firefox-version.mjs", 15 | "check:chrome": "node utils/check-chrome-version.mjs" 16 | }, 17 | "eslintConfig": { 18 | "env": { 19 | "browser": true, 20 | "jasmine": true 21 | }, 22 | "rules": { 23 | "no-var": 0, 24 | "no-unused-vars": 0, 25 | "no-use-before-define": 0 26 | } 27 | }, 28 | "standardx": { 29 | "global": [ 30 | "chrome", 31 | "Mustache", 32 | "fetch", 33 | "Popup", 34 | "pluck" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | codeql-sast: 11 | name: CodeQL SAST scan 12 | uses: alphagov/govuk-infrastructure/.github/workflows/codeql-analysis.yml@main 13 | permissions: 14 | security-events: write 15 | 16 | dependency-review: 17 | name: Dependency Review scan 18 | uses: alphagov/govuk-infrastructure/.github/workflows/dependency-review.yml@main 19 | 20 | test: 21 | name: Test Extension JS 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup Node 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version-file: '.node-version' 31 | 32 | - name: Install Dependencies 33 | shell: bash 34 | run: npm install 35 | 36 | - name: Lint 37 | run: npm run lint:js 38 | 39 | - name: Run Jasmine 40 | run: npm test 41 | -------------------------------------------------------------------------------- /spec/javascripts/show_meta_tags.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* global ShowMetaTagsComponent loadFixtures */ 3 | 4 | describe('Toggling meta tags', function () { 5 | var showMetaTagsBannerId = 'govuk-chrome-toolkit-banner' 6 | var showMetaTagsBannerElement 7 | var showMetaTagsComponent 8 | 9 | beforeEach(function () { 10 | window.chrome = { 11 | runtime: { 12 | onMessage: { 13 | addListener: function () {} 14 | }, 15 | sendMessage: function () {} 16 | } 17 | } 18 | 19 | loadFixtures('meta-tags.html') 20 | 21 | showMetaTagsComponent = new ShowMetaTagsComponent() 22 | showMetaTagsComponent.toggleMetaTags() 23 | 24 | showMetaTagsBannerElement = document.querySelector(`#${showMetaTagsBannerId}`) 25 | }) 26 | 27 | it('shows meta tags with name and content', function () { 28 | expect(showMetaTagsBannerElement.textContent).toMatch(/foo/) 29 | }) 30 | 31 | it("doesn't show meta tags that use property instead of name", function () { 32 | // No particular reason for this, it just doesn't 33 | expect(showMetaTagsBannerElement.textContent).not.toMatch(/og:image/) 34 | }) 35 | 36 | it('removes the banner when toggled off', function () { 37 | showMetaTagsComponent.toggleMetaTags() 38 | 39 | expect(showMetaTagsBannerElement).not.toBeVisible() 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /spec/support/jasmine-browser.json: -------------------------------------------------------------------------------- 1 | { 2 | "srcDir": "src", 3 | "srcFiles": [ 4 | "**/ab_bucket_store.js", 5 | "lib/jquery.min.js", 6 | "popup/ab_tests.js", 7 | "popup/content_links.js", 8 | "popup/environment.js", 9 | "popup/extract_path.js", 10 | "popup/external_links.js", 11 | "components/content-blocks-component/content-blocks-component.js", 12 | "components/highlight-component/highlight-component.js", 13 | "components/design-mode-component/design-mode-component.js", 14 | "components/show-meta-tags-component/show-meta-tags-component.js" 15 | ], 16 | "specDir": "spec", 17 | "specFiles": [ 18 | "**/content_blocks_component.spec.js", 19 | "**/show_meta_tags.spec.js", 20 | "**/highlight_component.spec.js", 21 | "**/extract_path.spec.js", 22 | "**/external_links.spec.js", 23 | "**/environment.spec.js", 24 | "**/design_mode_component.spec.js", 25 | "**/content_links.spec.js", 26 | "**/ab_tests.spec.js", 27 | "**/ab_bucket_store.spec.js" 28 | ], 29 | "helpers": [ 30 | "helpers/jquery.min.js", 31 | "helpers/jasmine-jquery.js", 32 | "helpers/helpers.js", 33 | "helpers/polyfills/String.startsWith.js" 34 | ], 35 | "env": { 36 | "stopSpecOnExpectationFailure": false, 37 | "stopOnSpecFailure": false, 38 | "random": true 39 | }, 40 | "browser": { 41 | "name": "headlessChrome" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/service_utils/ab_bucket_store.js: -------------------------------------------------------------------------------- 1 | // This script is executed in the background. 2 | // 3 | // It stores the environment-specific state of the user's A/B testing buckets in 4 | // memory, until the browser or the extension is next reloaded. 5 | /* global abTest */ 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | var abBucketStore = (function () { 9 | function createStore () { 10 | var abTestBuckets = {} 11 | 12 | function addAbTests (initialBuckets, hostname) { 13 | abTestBuckets[hostname] = abTestBuckets[hostname] || {} 14 | 15 | Object.keys(initialBuckets).forEach(function (testName) { 16 | // Add any A/B tests that are not already defined, but do not overwrite 17 | // any that we are already tracking. 18 | if (!abTestBuckets[hostname][testName]) { 19 | abTestBuckets[hostname][testName] = initialBuckets[testName] 20 | } 21 | }) 22 | } 23 | 24 | function getAll (hostname) { 25 | return abTestBuckets[hostname] || {} 26 | } 27 | 28 | function setBucket (testName, bucket, hostname) { 29 | /* eslint-disable-next-line */ 30 | abTest = abTestBuckets[hostname][testName] 31 | abTest.currentBucket = bucket 32 | abTestBuckets[hostname][testName] = abTest 33 | } 34 | 35 | return { 36 | addAbTests: addAbTests, 37 | getAll: getAll, 38 | setBucket: setBucket 39 | } 40 | } 41 | 42 | return { 43 | createStore: createStore 44 | } 45 | }()) 46 | -------------------------------------------------------------------------------- /src/popup/extract_path.js: -------------------------------------------------------------------------------- 1 | var Popup = Popup || {} 2 | 3 | // Extract the relevant path from a location, such as `/foo` from URLs like 4 | // `www.gov.uk/foo` and `www.gov.uk/api/content/foo`. 5 | Popup.extractPath = function (location, pathname, renderingApplication) { 6 | const url = new URL(location) 7 | var extractedPath 8 | 9 | if (location.includes('api/content')) { 10 | extractedPath = pathname.replace('api/content/', '') 11 | } else if (location.includes('anonymous_feedback')) { 12 | extractedPath = extractQueryParameter(location, 'path') 13 | } else if (location.includes('content-data')) { 14 | extractedPath = pathname.replace('metrics/', '') 15 | } else if (/\.?nationalarchives\.gov\.uk$/.test(url.hostname)) { 16 | extractedPath = pathname.split('https://www.gov.uk')[1] 17 | } else if (location.includes('api/search.json')) { 18 | extractedPath = extractQueryParameter(location, 'filter_link') 19 | } else if (/api.*\.json/.test(location)) { 20 | extractedPath = pathname.replace('api/', '').replace('.json', '') 21 | } else if (location.includes('visualise')) { 22 | extractedPath = pathname.replace('/y/visualise', '') 23 | } else if (/www|draft-origin/.test(location)) { 24 | extractedPath = pathname 25 | } 26 | 27 | if (extractedPath) { 28 | return extractedPath.replace('//', '/') 29 | } 30 | 31 | function extractQueryParameter (location, parameterName) { 32 | return location.split(parameterName + '=')[1].split('&')[0] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/fetch-page-data.js: -------------------------------------------------------------------------------- 1 | // This script is called by the popup, but runs inside the main thread, so it 2 | // has access to the current page. It sends a message back to the popup with 3 | // the information needed to render it. 4 | 5 | chrome.runtime.sendMessage({ 6 | action: 'populatePopup', 7 | currentLocation: window.location.href, 8 | currentHost: window.location.hostname, 9 | currentOrigin: window.location.origin, 10 | currentPathname: window.location.pathname, 11 | renderingApplication: getMetatag('govuk:rendering-app'), 12 | abTestBuckets: getAbTestBuckets(), 13 | windowHeight: window.innerHeight, 14 | highlightState: false // window.highlightComponent.state 15 | }) 16 | 17 | function getMetatag (name) { 18 | var meta = document.getElementsByTagName('meta')[name] 19 | return meta && meta.getAttribute('content') 20 | } 21 | 22 | function getAbTestBuckets () { 23 | var abMetaTags = document.querySelectorAll('meta[name="govuk:ab-test"]') 24 | 25 | var metaTagPattern = /([\w-]+):([\w-]+)/ 26 | var buckets = {} 27 | 28 | abMetaTags.forEach(function (metaTag) { 29 | var testNameAndBucket = metaTagPattern.exec(metaTag.content) 30 | var testName = testNameAndBucket[1] 31 | var currentBucket = testNameAndBucket[2] 32 | var allowedBuckets = 33 | (metaTag.dataset.allowedVariants || 'A,B').split(',') 34 | 35 | buckets[testName] = { 36 | currentBucket: currentBucket, 37 | allowedBuckets: allowedBuckets 38 | } 39 | }) 40 | 41 | return buckets 42 | } 43 | -------------------------------------------------------------------------------- /src/service_utils/icon.js: -------------------------------------------------------------------------------- 1 | // This script runs in the service worker in Chrome. It will activate the small 2 | // greyed out GOV.UK logo in the Chrome menu bar whenever we're on a gov.uk page. 3 | chrome.declarativeContent.onPageChanged.removeRules(async () => { 4 | chrome.declarativeContent.onPageChanged.addRules([{ 5 | conditions: [ 6 | new chrome.declarativeContent.PageStateMatcher({ 7 | pageUrl: { hostSuffix: 'www.gov.uk' } 8 | }), 9 | new chrome.declarativeContent.PageStateMatcher({ 10 | pageUrl: { hostSuffix: 'dev.gov.uk' } 11 | }), 12 | new chrome.declarativeContent.PageStateMatcher({ 13 | pageUrl: { hostSuffix: 'publishing.service.gov.uk' } 14 | }) 15 | ], 16 | actions: [ 17 | new chrome.declarativeContent.SetIcon({ 18 | imageData: { 19 | 19: await loadImageData('icons/crown-logo-19-active.png'), 20 | 38: await loadImageData('icons/crown-logo-38-active.png') 21 | } 22 | }), 23 | chrome.declarativeContent.ShowAction 24 | ? new chrome.declarativeContent.ShowAction() 25 | : new chrome.declarativeContent.ShowPageAction() 26 | ] 27 | }]) 28 | }) 29 | 30 | async function loadImageData (url) { 31 | const img = await createImageBitmap(await (await fetch(url)).blob()) 32 | const { width: w, height: h } = img 33 | const canvas = new OffscreenCanvas(w, h) 34 | const ctx = canvas.getContext('2d') 35 | ctx.drawImage(img, 0, 0, w, h) 36 | return ctx.getImageData(0, 0, w, h) 37 | } 38 | -------------------------------------------------------------------------------- /src/components/design-mode-component/design-mode-component.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* global DesignModeComponent */ 3 | 4 | function DesignModeComponent () { 5 | this.state = false 6 | chrome.runtime.onMessage.addListener(function (request) { 7 | if (request.trigger === 'toggleDesignMode') { 8 | this.toggleDesignMode() 9 | } 10 | }.bind(this)) 11 | } 12 | 13 | DesignModeComponent.prototype.toggleDesignMode = function () { 14 | this.state = !this.state 15 | 16 | window.document.designMode = this.state ? 'on' : 'off' 17 | this.toggleDesignModeBanner() 18 | this.sendState() 19 | } 20 | 21 | DesignModeComponent.prototype.toggleDesignModeBanner = function () { 22 | var designModeBannerId = 'govuk-chrome-toolkit-design-mode-banner' 23 | if (this.state) { 24 | var designModeBanner = ` 25 | 30 | ` 31 | var designModeWrapper = document.createElement('div') 32 | designModeWrapper.innerHTML = designModeBanner 33 | document.body.prepend(designModeWrapper) 34 | } else { 35 | var designModeBannerElement = document.querySelector(`#${designModeBannerId}`) 36 | designModeBannerElement.remove() 37 | } 38 | } 39 | 40 | DesignModeComponent.prototype.sendState = function () { 41 | chrome.runtime.sendMessage({ 42 | action: 'designModeState', 43 | designModeState: this.state 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /spec/javascripts/design_mode_component.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* global DesignModeComponent */ 3 | 4 | describe('Toggling design mode', function () { 5 | var designModeBannerId = 'govuk-chrome-toolkit-design-mode-banner' 6 | var designModeBannerElement 7 | var designModeComponent 8 | 9 | beforeEach(function () { 10 | // Mock addListener function to call toggleDesignMode trigger when initialized 11 | window.chrome = { 12 | runtime: { 13 | onMessage: { 14 | addListener: function (callback) { 15 | /* eslint-disable-next-line */ 16 | callback({ trigger: 'toggleDesignMode' }) 17 | } 18 | }, 19 | sendMessage: function () {} 20 | } 21 | } 22 | designModeComponent = new DesignModeComponent() 23 | designModeBannerElement = document.querySelector(`#${designModeBannerId}`) 24 | }) 25 | 26 | it('shows design mode banner', function () { 27 | expect(designModeBannerElement.textContent).toMatch(/You are in design mode./) 28 | }) 29 | 30 | it('removes the banner when toggled off', function () { 31 | designModeComponent.toggleDesignMode() 32 | expect(designModeBannerElement).not.toBeVisible() 33 | }) 34 | 35 | it('design mode is on when toggled on', function () { 36 | expect(window.document.designMode).toEqual('on') 37 | }) 38 | 39 | it('design mode is off when toggled off', function () { 40 | designModeComponent.toggleDesignMode() 41 | expect(window.document.designMode).toEqual('off') 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /src/manifest_base.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "GOV.UK Browser Extension", 4 | "description": "Switch between GOV.UK environments and content", 5 | "homepage_url": "https://github.com/alphagov/govuk-browser-extension", 6 | "version": "1.30.2", 7 | "content_scripts": [ 8 | { 9 | "matches": 10 | [ 11 | "https://*.gov.uk/*" 12 | ], 13 | "all_frames": true, 14 | "js": [ 15 | "popup/lib/mustache.min.js", 16 | "components/highlight-component/highlight-component.js", 17 | "components/content-blocks-component/content-blocks-component.js", 18 | "components/design-mode-component/design-mode-component.js", 19 | "components/show-meta-tags-component/show-meta-tags-component.js" 20 | ], 21 | "css": [ 22 | "components/highlight-component/highlight-component.css", 23 | "components/content-blocks-component/content-blocks-component.css", 24 | "components/design-mode-component/design-mode-component.css", 25 | "components/show-meta-tags-component/show-meta-tags-component.css" 26 | ] 27 | } 28 | ], 29 | "icons": { 30 | "48": "icons/48.png", 31 | "128": "icons/128.png" 32 | }, 33 | "permissions": [ 34 | "cookies", 35 | "declarativeContent", 36 | "scripting", 37 | "tabs", 38 | "webRequest" 39 | ], 40 | "action": { 41 | "default_icon": { 42 | "19": "icons/crown-logo-19-inactive.png", 43 | "38": "icons/crown-logo-38-inactive.png" 44 | }, 45 | "default_title": "GOV.UK", 46 | "default_popup": "popup.html" 47 | }, 48 | "content_security_policy": {}, 49 | "host_permissions": [ 50 | "http://*.gov.uk/*", 51 | "https://*.gov.uk/*" 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /src/popup/popup.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | width: 550px; 5 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 6 | } 7 | 8 | #content .loading-message { 9 | text-align: center; 10 | padding: 20px; 11 | } 12 | 13 | .envs { 14 | padding: 0; 15 | background-color: #0B0C0C; 16 | } 17 | 18 | .envs a { 19 | color: #fff; 20 | padding: 15px 8px; 21 | display: inline-block; 22 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0.3); 23 | } 24 | 25 | .envs a.current, 26 | .envs a:hover { 27 | background-color: #1d70b8; 28 | } 29 | 30 | .add-top-border { 31 | margin-top: 10px; 32 | padding-top: 10px; 33 | border-top: 1px solid #ccc; 34 | } 35 | 36 | .add-top-border:empty { 37 | display: none; 38 | } 39 | 40 | li a { 41 | display: block; 42 | padding: 7px 10px; 43 | color: #0B0C0C; 44 | text-decoration: none; 45 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0.3); 46 | } 47 | 48 | li a:focus, 49 | .envs a:focus { 50 | color: #0B0C0C; 51 | background-color: #ffdd00; 52 | outline: 3px solid transparent; 53 | box-shadow: 0 -2px #ffdd00, 0 4px #0b0c0c; 54 | text-decoration: none; 55 | } 56 | 57 | li a img { 58 | vertical-align: middle; 59 | } 60 | 61 | li a.current, 62 | li a:hover { 63 | background-color: #1d70b8; 64 | color: #fff; 65 | } 66 | 67 | .ab-test-name, .ab-test-bucket { 68 | display: inline-block; 69 | vertical-align: middle; 70 | padding-left: 10px; 71 | } 72 | 73 | .ab-test-name { 74 | width: 250px; 75 | white-space: nowrap; 76 | overflow: hidden; 77 | text-overflow: ellipsis; 78 | } 79 | 80 | .ab-test-bucket { 81 | padding: 6px 9px; 82 | cursor: pointer; 83 | } 84 | 85 | .ab-bucket-selected, .ab-test-bucket:hover { 86 | color: #fff; 87 | background-color: #1d70b8; 88 | } 89 | -------------------------------------------------------------------------------- /docs/releasing.md: -------------------------------------------------------------------------------- 1 | # Releasing the extension 2 | 3 | 1. Install `jq`. For example, on mac, you can do it using brew 'brew install jq' 4 | 2. Update the version in `manifest_base.json` 5 | 3. Run `npm run build` 6 | 4. Create a Pull Request with the new package committed 7 | 5. Once the Pull Request is merged, the latest version is released via the [Release workflow](https://github.com/alphagov/govuk-browser-extension/blob/main/.github/workflows/release.yml) 8 | 9 | ## How the secrets are managed 10 | 11 | Ths secrets for both platforms are stored in the repo, and managed as follows: 12 | 13 | ### Firefox 14 | 15 | Extension API keys managed via the shared account in the [Firefox developer hub](https://addons.mozilla.org/en-US/developers/) 16 | 17 | Account details are in the [AWS Secrets Manager](https://eu-west-1.console.aws.amazon.com/secretsmanager). See the 18 | documentation in [Retrieve a credential from Secrets Manager](https://docs.publishing.service.gov.uk/manual/secrets-manager.html#retrieve-a-credential-from-secrets-manager) 19 | 20 | ### Chrome 21 | 22 | There is a `chrome-webstore-upload` project in Google Cloud, which is accessible by everyone in the 23 | google-chrome-developers@digital.cabinet-office.gov.uk group. 24 | 25 | If you do not have access to the group, then you can ask to be added to the 26 | [govuk google chrome developers google group](https://groups.google.com/a/digital.cabinet-office.gov.uk/g/google-chrome-developers). 27 | 28 | If you need to regenerate the API keys for any reason, you can [follow the instructions here](https://github.com/fregante/chrome-webstore-upload-keys?tab=readme-ov-file) 29 | 30 | ## Note 31 | 32 | Firefox and chrome currently disagree on few things with respect to V3 of manifest.json, so inorder to accommodate for 33 | both the browser, we would need a separate build for each browser with their manifest.json catering to each of them. To 34 | do this, we have created two manifest.json for each browser and have updated build script to generate separate 35 | manifest.json for each of them during the build. 36 | -------------------------------------------------------------------------------- /src/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 60 | 61 |31 | title (${titleText.length}): 32 | ${titleText} 33 |
34 | ` 35 | 36 | var metaTagContainer = document.createElement('div') 37 | metaTagContainer.setAttribute('id', 'govuk-chrome-toolkit-banner') 38 | // insert titleTag into metaTagContainer 39 | metaTagContainer.insertAdjacentHTML('beforeend', titleTag) 40 | 41 | var metaTags = document.querySelectorAll('meta') 42 | metaTags.forEach(function (metaTag) { 43 | var metaTagName = metaTag.getAttribute('name') 44 | if (metaTagName === null) { 45 | return 46 | } 47 | 48 | var metaTagContent = metaTag.getAttribute('content') 49 | if (metaTagContent === null) { 50 | metaTagContent = '' 51 | } 52 | 53 | var metaTagInfo = ` 54 |55 | ${metaTagName} (${metaTagContent.length}): 56 | ${metaTagContent} 57 |
58 | ` 59 | 60 | // insert metaTagInfo into metaTagContainer 61 | metaTagContainer.insertAdjacentHTML('beforeend', metaTagInfo) 62 | }) 63 | 64 | document.body.prepend(metaTagContainer) 65 | 66 | this.isMetaTagsDisplayed = true 67 | } 68 | 69 | ShowMetaTagsComponent.prototype.hideMetaTags = function () { 70 | var hideMetaTagsBanner = document.querySelector('#govuk-chrome-toolkit-banner') 71 | hideMetaTagsBanner.remove() 72 | 73 | this.isMetaTagsDisplayed = false 74 | } 75 | 76 | ShowMetaTagsComponent.prototype.sendState = function () { 77 | chrome.runtime.sendMessage({ 78 | action: 'showMetaTagsState', 79 | metaTagsState: this.isMetaTagsDisplayed 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /src/service_utils/ab_test_settings.js: -------------------------------------------------------------------------------- 1 | // This script is executed in the background. 2 | // 3 | // - It sets the appropriate request headers like `GOVUK-ABTest-NewNavigation` 4 | // so that applications in integration, staging and development will respond 5 | // with the correct A/B variant. It gets the current variant from the meta tags. 6 | // - Responds to messages to change the current A/B variant. It updates the 7 | // headers it will send and set a cookie like Fastly would. 8 | var abTestSettings = (function () { 9 | var abBucketStore = chrome.extension.getBackgroundPage().abBucketStore.createStore() 10 | 11 | function initialize (initialBuckets, url) { 12 | var hostname = extractHostname(url) 13 | 14 | abBucketStore.addAbTests(initialBuckets, hostname) 15 | return abBucketStore.getAll(hostname) 16 | } 17 | 18 | function updateCookie (name, bucket, url, callback) { 19 | var cookieName = 'ABTest-' + name 20 | 21 | chrome.cookies.get({ name: cookieName, url: url }, function (cookie) { 22 | if (cookie) { 23 | cookie.value = bucket 24 | 25 | var updatedCookie = { 26 | name: cookieName, 27 | value: bucket, 28 | url: url, 29 | path: cookie.path, 30 | expirationDate: cookie.expirationDate 31 | } 32 | 33 | chrome.cookies.set(updatedCookie, callback) 34 | } else { 35 | callback() 36 | } 37 | }) 38 | } 39 | 40 | function setBucket (testName, bucketName, url, callback) { 41 | abBucketStore.setBucket(testName, bucketName, extractHostname(url)) 42 | updateCookie(testName, bucketName, url, callback) 43 | } 44 | 45 | function addAbHeaders (details) { 46 | var abTestBuckets = abBucketStore.getAll(extractHostname(details.url)) 47 | 48 | Object.keys(abTestBuckets).forEach(function (abTestName) { 49 | details.requestHeaders.push({ 50 | name: 'GOVUK-ABTest-' + abTestName, 51 | value: abTestBuckets[abTestName].currentBucket 52 | }) 53 | }) 54 | 55 | return { requestHeaders: details.requestHeaders } 56 | } 57 | 58 | function extractHostname (url) { 59 | return new URL(url).hostname 60 | } 61 | 62 | chrome.webRequest.onBeforeSendHeaders.addListener( 63 | addAbHeaders, 64 | { urls: ['*://*.gov.uk/*'] }, 65 | ['requestHeaders', 'blocking'] 66 | ) 67 | 68 | return { 69 | initialize: initialize, 70 | setBucket: setBucket 71 | } 72 | }()) 73 | -------------------------------------------------------------------------------- /spec/javascripts/ab_tests.spec.js: -------------------------------------------------------------------------------- 1 | describe('Popup.findActiveAbTests', function () { 2 | it('returns no A/B tests if none are active', function () { 3 | var abTests = Popup.findActiveAbTests({}) 4 | 5 | expect(abTests).toEqual([]) 6 | }) 7 | 8 | it('finds all A/B tests', function () { 9 | var abTests = Popup.findActiveAbTests({ 10 | 'first-AB-test-name': { 11 | currentBucket: 'some-value', 12 | allowedBuckets: ['some-value', 'B'] 13 | }, 14 | 'second-AB-test-name': { 15 | currentBucket: 'other-value', 16 | allowedBuckets: ['other-value', 'B'] 17 | }, 18 | 'third-AB-test-name': { 19 | currentBucket: 'yet-another-value', 20 | allowedBuckets: ['A', 'yet-another-value'] 21 | } 22 | }) 23 | 24 | expect(abTests.length).toEqual(3) 25 | expect(abTests[0].testName).toEqual('first-AB-test-name') 26 | expect(abTests[1].testName).toEqual('second-AB-test-name') 27 | expect(abTests[2].testName).toEqual('third-AB-test-name') 28 | }) 29 | 30 | it('returns A and B buckets', function () { 31 | var abTests = Popup.findActiveAbTests({ 32 | 'some-AB-test-name': { 33 | currentBucket: 'A', 34 | allowedBuckets: ['A', 'B'] 35 | } 36 | }) 37 | 38 | expect(abTests[0].buckets.length).toEqual(2) 39 | expect(abTests[0].buckets[0].bucketName).toEqual('A') 40 | expect(abTests[0].buckets[1].bucketName).toEqual('B') 41 | }) 42 | 43 | it("highlights 'A' bucket if user is in 'A' group", function () { 44 | var abTests = Popup.findActiveAbTests({ 45 | 'some-AB-test-name': { 46 | currentBucket: 'B', 47 | allowedBuckets: ['A', 'B'] 48 | }, 49 | 'other-AB-test-name': { 50 | currentBucket: 'A', 51 | allowedBuckets: ['A', 'B'] 52 | } 53 | }) 54 | 55 | expect(abTests[0].buckets[0].class).toEqual('') 56 | expect(abTests[0].buckets[1].class).toEqual('ab-bucket-selected') 57 | 58 | expect(abTests[1].buckets[0].class).toEqual('ab-bucket-selected') 59 | expect(abTests[1].buckets[1].class).toEqual('') 60 | }) 61 | 62 | it("doesn't highlight any buckets if variant is unknown", function () { 63 | var abTests = Popup.findActiveAbTests({ 64 | 'some-AB-test-name': { 65 | currentBucket: 'Unknown', 66 | allowedBuckets: ['A', 'B'] 67 | } 68 | }) 69 | 70 | expect(abTests[0].buckets[0].class).toEqual('') 71 | expect(abTests[0].buckets[1].class).toEqual('') 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /src/popup/content_links.js: -------------------------------------------------------------------------------- 1 | var Popup = Popup || {} 2 | 3 | // Given a location, generate links to different content presentations 4 | Popup.generateContentLinks = function (location, origin, pathname, currentEnvironment, renderingApplication) { 5 | var path = Popup.extractPath(location, pathname, renderingApplication) 6 | 7 | // If no path can be found (which means we're probably in a publishing app) 8 | // Similarly if we're on GOVUK account, not many of the links are relevant 9 | if (!path || origin.includes('www.account')) { 10 | return [] 11 | } 12 | 13 | // Origin looks like 'https://www.gov.uk' or 'https://www-origin.integration.publishing.service.gov.uk/' or similar. 14 | if (origin === 'http://webarchive.nationalarchives.gov.uk' || 15 | /draft-origin|content-data|support/.test(origin)) { 16 | origin = 'https://www.gov.uk' 17 | } 18 | 19 | var links = [] 20 | 21 | // If we're on the homepage there's not much to show. 22 | links.push({ name: 'On GOV.UK', url: origin + path }) 23 | links.push({ name: 'Content item (JSON)', url: currentEnvironment.origin + '/api/content' + path }) 24 | links.push({ name: 'Search data (JSON)', url: origin + '/api/search.json?filter_link=' + path }) 25 | links.push({ name: 'Draft (may not always work)', url: currentEnvironment.protocol + '://draft-origin.' + currentEnvironment.serviceDomain + path }) 26 | links.push({ name: 'User feedback', url: currentEnvironment.protocol + '://support.' + currentEnvironment.serviceDomain + '/anonymous_feedback?path=' + path }) 27 | links.push({ name: 'National Archives', url: 'http://webarchive.nationalarchives.gov.uk/*/https://www.gov.uk' + path }) 28 | links.push({ name: 'View data about page on Content Data', url: currentEnvironment.protocol + '://content-data.' + currentEnvironment.serviceDomain + '/metrics' + path }) 29 | links.push({ name: 'Check for content problems in Siteimprove', url: 'https://my2.siteimprove.com/QualityAssurance/1054012/Overview/Search?SearchIn=Url&Query=' + path }) 30 | links.push({ name: 'View structured data', url: 'https://search.google.com/structured-data/testing-tool/u/0/#url=https://www.gov.uk' + path }) 31 | 32 | var currentUrl = origin + path 33 | 34 | if (renderingApplication === 'smartanswers') { 35 | links.push({ name: 'SmartAnswers: Visualise', url: currentUrl.replace(/\/y.*$/, '') + '/y/visualise' }) 36 | } 37 | 38 | return links.map(function (link) { 39 | link.class = link.url === location ? 'current' : '' 40 | return link 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /spec/javascripts/environment.spec.js: -------------------------------------------------------------------------------- 1 | describe('Popup.environment', function () { 2 | function createEnvironmentForUrl (location) { 3 | var a = document.createElement('a') 4 | a.href = location 5 | return Popup.environment(a.href, a.hostname, a.origin).allEnvironments 6 | } 7 | 8 | it('returns the correct environment links when the user is on production', function () { 9 | var envs = createEnvironmentForUrl( 10 | 'https://www.gov.uk/browse/disabilities?foo=bar' 11 | ) 12 | 13 | var urls = pluck(envs, 'url') 14 | 15 | expect(urls).toEqual([ 16 | 'https://www.gov.uk/browse/disabilities?foo=bar', 17 | 'https://www.staging.publishing.service.gov.uk/browse/disabilities?foo=bar', 18 | 'https://www.integration.publishing.service.gov.uk/browse/disabilities?foo=bar', 19 | 'http://www.dev.gov.uk/browse/disabilities?foo=bar' 20 | ]) 21 | }) 22 | 23 | it('returns the correct variants for development', function () { 24 | var envs = createEnvironmentForUrl( 25 | 'http://www.dev.gov.uk/browse/disabilities?foo=bar' 26 | ) 27 | 28 | var urls = pluck(envs, 'url') 29 | 30 | expect(urls).toEqual([ 31 | 'https://www.gov.uk/browse/disabilities?foo=bar', 32 | 'https://www.staging.publishing.service.gov.uk/browse/disabilities?foo=bar', 33 | 'https://www.integration.publishing.service.gov.uk/browse/disabilities?foo=bar', 34 | 'http://www.dev.gov.uk/browse/disabilities?foo=bar' 35 | ]) 36 | }) 37 | 38 | it('shows production, staging, integration and development on publisher-apps', function () { 39 | var envs = createEnvironmentForUrl( 40 | 'https://signon.publishing.service.gov.uk/' 41 | ) 42 | 43 | var environmentNames = pluck(envs, 'name') 44 | 45 | expect(environmentNames).toEqual([ 46 | 'Production', 'Staging', 'Integration', 'Development' 47 | ]) 48 | }) 49 | 50 | it('Only shows production, staging and development on the GOVUK Account', function () { 51 | var envs = createEnvironmentForUrl( 52 | 'https://www.account.publishing.service.gov.uk/' 53 | ) 54 | 55 | var environmentNames = pluck(envs, 'name') 56 | 57 | expect(environmentNames).toEqual([ 58 | 'Production', 'Staging', 'Development' 59 | ]) 60 | }) 61 | 62 | it('correctly identifies the current environment', function () { 63 | var forProd = createEnvironmentForUrl( 64 | 'https://www.gov.uk/browse/disabilities?foo=bar' 65 | ) 66 | 67 | expect(forProd[0].class).toEqual('current') 68 | }) 69 | 70 | it('returns nothing if not on GOV.UK', function () { 71 | var links = createEnvironmentForUrl( 72 | 'http://webarchive.nationalarchives.gov.uk/*/https://www.gov.uk/jobsearch' 73 | ) 74 | 75 | expect(links).toEqual({ name: 'GOV.UK', url: 'https://www.gov.uk' }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /spec/javascripts/extract_path.spec.js: -------------------------------------------------------------------------------- 1 | describe('Popup.extractPath', function () { 2 | it('returns nothing for publishing applications', function () { 3 | var path = Popup.extractPath('https://publisher.publishing.service.gov.uk/foo/bar', '/foo/bar') 4 | 5 | expect(path).toBeUndefined() 6 | }) 7 | 8 | it('returns the path for draft URLs', function () { 9 | var path = Popup.extractPath('https://draft-origin.staging.publishing.service.gov.uk/browse/disabilities', '/browse/disabilities') 10 | 11 | expect(path).toEqual('/browse/disabilities') 12 | }) 13 | 14 | it('returns the path for frontend applications', function () { 15 | var path = Popup.extractPath('https://www.gov.uk/browse/disabilities', '/browse/disabilities') 16 | 17 | expect(path).toBe('/browse/disabilities') 18 | }) 19 | 20 | it('returns the path for origin frontend applications', function () { 21 | var path = Popup.extractPath('https://www-origin.gov.uk/browse/disabilities', '/browse/disabilities') 22 | 23 | expect(path).toBe('/browse/disabilities') 24 | }) 25 | 26 | it('returns the path for content-store pages', function () { 27 | var path = Popup.extractPath('https://www.gov.uk/api/content/browse/disabilities', '/api/content/browse/disabilities') 28 | 29 | expect(path).toBe('/browse/disabilities') 30 | }) 31 | 32 | it('returns the path for URLs with double slashes', function () { 33 | var path = Popup.extractPath('https://www.gov.uk/api/content//browse/disabilities', '/api/content//browse/disabilities') 34 | 35 | expect(path).toBe('/browse/disabilities') 36 | }) 37 | 38 | it('returns the path for search pages', function () { 39 | var path = Popup.extractPath('https://www.gov.uk/api/search.json?filter_link=/browse/disabilities', '/api/search.json') 40 | 41 | expect(path).toBe('/browse/disabilities') 42 | }) 43 | 44 | it('returns the path for cache-busted search pages', function () { 45 | var path = Popup.extractPath('https://www.gov.uk/api/search.json?filter_link=/browse/disabilities&c=some_cache_buster', '/api/search.json') 46 | 47 | expect(path).toBe('/browse/disabilities') 48 | }) 49 | 50 | it('returns the path for national archives pages', function () { 51 | var path = Popup.extractPath('http://webarchive.nationalarchives.gov.uk/*/https://www.gov.uk/some/page', '/*/https://www.gov.uk/some/page') 52 | 53 | expect(path).toBe('/some/page') 54 | }) 55 | 56 | it('returns the path for the smart answers visualisation', function () { 57 | var path = Popup.extractPath('https://www.gov.uk/maternity-paternity-calculator/y/visualise', '/maternity-paternity-calculator/y/visualise') 58 | 59 | expect(path).toBe('/maternity-paternity-calculator') 60 | }) 61 | 62 | it('returns the path for the content data manager', function () { 63 | var path = Popup.extractPath('https://content-data.publishing.service.gov.uk/metrics/browse/disabilities', '/metrics/browse/disabilities') 64 | 65 | expect(path).toBe('/browse/disabilities') 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GOV.UK Toolkit for Chrome and Firefox 2 | 3 | Allows easy switching between the different GOV.UK environments and content representations. Inspired by the [govuk-bookmarklets](https://github.com/dsingleton/govuk-bookmarklets). 4 | 5 |  6 | 7 | ## Installation 8 | 9 | The extension is [downloadable on the Chrome web store](https://chrome.google.com/webstore/detail/govuk-toolkit/dclfaikcemljbaoagjnedmlppnbiljen) and [AMO for Firefox](https://addons.mozilla.org/en-GB/firefox/addon/govuk-browser-extension-ff/). 10 | 11 | If you don't want to install from your browser's web store for security reasons, you can install a local non-self updating copy. 12 | 13 | ### For Chrome: 14 | 15 | 1. [Download the source from GitHub](https://github.com/alphagov/govuk-browser-extension/archive/main.zip) and unzip. 16 | 2. Visit [chrome://extensions](chrome://extensions) in your browser. 17 | 3. Ensure that the Developer mode checkbox in the top right-hand corner is checked. 18 | 4. Click `Load unpacked extension…` to pop up a file selection dialog. 19 | 5. Navigate to `src` in the extension directory, and select it. 20 | 6. Visit any page on [GOV.UK](https://www.gov.uk). 21 | 22 | Source: [Getting Started: Building a Chrome Extension](https://developer.chrome.com/extensions/getstarted#unpacked). 23 | 24 | ### For Firefox: 25 | 26 | Extensions installed using the following instructions are only active while Firefox 27 | is open and are removed on exit. Permanently-active extensions can be only be 28 | installed from packages signed by Mozilla. 29 | 30 | 1. [Download the source from GitHub](https://github.com/alphagov/govuk-browser-extension/archive/main.zip) and unzip. 31 | 2. Visit [about:debugging](about:debugging/runtime/this-firefox) in your browser. 32 | 3. Click `Load Temporary Add-on` to pop up a file selection dialog. 33 | 4. Navigate to `src` in the extension directory, and select `manifest.json`. 34 | 5. Visit any page on [GOV.UK](https://www.gov.uk). 35 | 36 | Source: [Temporary installation in Firefox](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Temporary_Installation_in_Firefox). 37 | 38 | ## Running the tests 39 | 40 | You'll need jasmine-browser, which you can set up with: 41 | 42 | ``` 43 | $ npm install 44 | ``` 45 | 46 | You can then run the tests with: 47 | 48 | ``` 49 | $ npm test 50 | ``` 51 | 52 | This will start a server and run the tests in a browser (chrome by default). 53 | 54 | If you want the browser to remain open with the test results, you can use 55 | 56 | ``` 57 | $ npx jasmine-browser-runner serve 58 | ``` 59 | 60 | ..then navigate to http://localhost:8888/ 61 | 62 | ## Releasing the extension 63 | 64 | 1. Update the version number in `src/manifest_base.json` 65 | 1. Run `build.sh` to create the build artifacts and ensure they're committed 66 | 1. Create a PR - when a new version is merged to `main`, a new version of the extension is automatically packaged up and published to 67 | Firefox Add-ons and to Chrome web store. See [releasing.md](https://github.com/alphagov/govuk-browser-extension/blob/main/docs/releasing.md) 68 | 69 | ### License 70 | 71 | MIT License 72 | -------------------------------------------------------------------------------- /src/popup/environment.js: -------------------------------------------------------------------------------- 1 | var Popup = Popup || {} 2 | 3 | // Given a location, hostname and origin, generate URLs for all GOV.UK environments. 4 | // 5 | // Returns a hash with envs, including one with `class: "current"` to show 6 | // the current environment. 7 | Popup.environment = function (location, host, origin) { 8 | function isPartOfGOVUK () { 9 | return /^(www|.*\.publishing\.service|(www\.)?dev)\.gov\.uk$/.test(host) 10 | } 11 | 12 | function isGOVUKAccount () { 13 | return /^(www\.account.*\.service\.gov\.uk|login\.service\.dev)$/.test(host) 14 | } 15 | 16 | if (!isPartOfGOVUK()) { 17 | return { 18 | allEnvironments: { 19 | name: 'GOV.UK', 20 | url: 'https://www.gov.uk' 21 | }, 22 | currentEnvironment: { 23 | name: 'Production', 24 | protocol: 'https', 25 | serviceDomain: 'publishing.service.gov.uk', 26 | host: 'https://www.gov.uk', 27 | origin: origin 28 | } 29 | } 30 | } 31 | 32 | var ENVIRONMENTS = [ 33 | { 34 | name: 'Production', 35 | protocol: 'https', 36 | serviceDomain: 'publishing.service.gov.uk', 37 | host: 'https://www.gov.uk', 38 | origin: origin 39 | }, 40 | { 41 | name: 'Staging', 42 | protocol: 'https', 43 | serviceDomain: 'staging.publishing.service.gov.uk', 44 | host: 'https://www.staging.publishing.service.gov.uk', 45 | origin: origin 46 | }, 47 | { 48 | name: 'Integration', 49 | protocol: 'https', 50 | serviceDomain: 'integration.publishing.service.gov.uk', 51 | host: 'https://www.integration.publishing.service.gov.uk', 52 | origin: origin 53 | }, 54 | { 55 | name: 'Development', 56 | protocol: 'http', 57 | serviceDomain: 'dev.gov.uk', 58 | host: 'http://www.dev.gov.uk', 59 | origin: origin 60 | } 61 | ] 62 | 63 | var application = isGOVUKAccount() ? host.split('.')[1] : host.split('.')[0] 64 | var inFrontend = application.includes('www') && !isGOVUKAccount() 65 | var environments = ENVIRONMENTS 66 | 67 | var currentEnvironment 68 | 69 | var allEnvironments = environments.map(function (env) { 70 | if (inFrontend) { 71 | var replacement = env.host 72 | } else if (isGOVUKAccount()) { 73 | replacement = env.protocol + '://www.' + application + '.' + env.serviceDomain 74 | } else { 75 | replacement = env.protocol + '://' + application + '.' + env.serviceDomain 76 | } 77 | 78 | env.url = location.replace(origin, replacement) 79 | if (env.name === 'Development' && isGOVUKAccount()) { 80 | // The GOV.UK Account is a special snowflake app with a special snowflake dev environment URL 81 | env.url = 'http://www.login.service.dev.gov.uk/' 82 | } 83 | 84 | if (location === env.url) { 85 | env.class = 'current' 86 | currentEnvironment = env 87 | } else { 88 | env.class = '' 89 | } 90 | 91 | return env 92 | }).filter(function (env) { 93 | // GOV.UK Account does not have an Integration environment – remove this option from the list of environments 94 | if (env.name === 'Integration' && isGOVUKAccount()) return false 95 | return env 96 | }) 97 | 98 | return { 99 | allEnvironments: allEnvironments, 100 | currentEnvironment: currentEnvironment 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/components/content-blocks-component/content-blocks-component.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | function ContentBlocksComponent () { 4 | this.contentBlocksHighlighted = false 5 | this.contentBlocks = Array.from(document.querySelectorAll('[data-content-block]')) 6 | 7 | chrome.runtime.onMessage.addListener(function (request) { 8 | if (request.trigger === 'toggleContentBlocks') { 9 | this.toggleHighlight() 10 | } 11 | }.bind(this)) 12 | } 13 | 14 | ContentBlocksComponent.prototype.toggleHighlight = function () { 15 | for (var i = 0; i < this.contentBlocks.length; i++) { 16 | this.contentBlocks[i].classList.toggle('highlight-content-block', !this.contentBlocksHighlighted) 17 | this.contentBlocks[i].setAttribute('data-content-block-page-identifier', `content_block_${i}`) 18 | } 19 | 20 | if (this.contentBlocksHighlighted) { 21 | this.hideContentBlocksBanner() 22 | } else { 23 | this.showContentBlocksBanner() 24 | } 25 | 26 | this.contentBlocksHighlighted = !this.contentBlocksHighlighted 27 | 28 | this.sendState() 29 | } 30 | 31 | ContentBlocksComponent.prototype.hideContentBlocksBanner = function () { 32 | var wrapper = document.querySelector('.govuk-chrome-content-blocks-banner') 33 | 34 | wrapper.remove() 35 | } 36 | 37 | ContentBlocksComponent.prototype.showContentBlocksBanner = function () { 38 | var wrapper = document.createElement('div') 39 | wrapper.classList.add('govuk-chrome-content-blocks-banner') 40 | 41 | var title = `No content blocks in use on this page
') 82 | } 83 | 84 | document.body.prepend(wrapper) 85 | } 86 | 87 | ContentBlocksComponent.prototype.sendState = function () { 88 | chrome.runtime.sendMessage({ 89 | action: 'contentBlockState', 90 | highlightState: this.contentBlocksHighlighted 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /spec/javascripts/content_blocks_component.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* global ContentBlocksComponent loadFixtures */ 4 | 5 | describe('Content blocks component', function () { 6 | var banner 7 | var contentBlocksComponent 8 | 9 | beforeEach(function () { 10 | window.chrome = { 11 | runtime: { 12 | onMessage: { 13 | addListener: function () {} 14 | }, 15 | sendMessage: function () {} 16 | } 17 | } 18 | }) 19 | 20 | describe('when a page has content blocks', function () { 21 | beforeEach(function () { 22 | loadFixtures('content-blocks.html') 23 | 24 | contentBlocksComponent = new ContentBlocksComponent() 25 | contentBlocksComponent.toggleHighlight() 26 | 27 | banner = document.querySelector('.govuk-chrome-content-blocks-banner') 28 | }) 29 | 30 | it('highlights content blocks', function () { 31 | var contentBlocks = document.querySelectorAll('.content-block') 32 | 33 | contentBlocks.forEach(function (el) { 34 | expect(el).toHaveClass('highlight-content-block') 35 | }) 36 | }) 37 | 38 | it('shows content blocks in the banner', function () { 39 | expect(banner).not.toBeNullish() 40 | 41 | var header = banner.querySelector('.govuk-chrome-content-blocks-banner__heading') 42 | var subheading = banner.querySelector('.govuk-chrome-content-blocks-banner__subhead') 43 | var buttons = banner.querySelectorAll('.govuk-chrome-content-blocks-banner__button') 44 | 45 | expect(header.innerText).toEqual('Content Blocks') 46 | expect(subheading.innerText).toEqual('email address - enquiries@companieshouse.gov.uk') 47 | expect(buttons.length).toEqual(2) 48 | }) 49 | 50 | it('allows content blocks to be linked to', function () { 51 | var realQuerySelector = document.querySelector 52 | var buttons = banner.querySelectorAll('.govuk-chrome-content-blocks-banner__button') 53 | 54 | var stubs = Array.from(buttons).map(function (button) { 55 | var stub = document.createElement('span') 56 | stub.setAttribute('data-content-block-page-identifier', button.dataset.contentBlockTarget) 57 | return stub 58 | }) 59 | 60 | spyOn(document, 'querySelector').and.callFake(function (selector) { 61 | var stub = stubs.find(function (stub) { 62 | return stub.matches(selector) 63 | }) 64 | 65 | if (stub) { 66 | return stub 67 | } else { 68 | return realQuerySelector.call(document, selector) 69 | } 70 | }) 71 | 72 | buttons.forEach(function (button) { 73 | var stub = stubs.find(function (stub) { 74 | return stub.dataset.contentBlockPageIdentifier === button.dataset.contentBlockTarget 75 | }) 76 | var scrollspy = spyOn(stub, 'scrollIntoView') 77 | 78 | button.dispatchEvent(new Event('click')) 79 | 80 | expect(scrollspy).toHaveBeenCalled() 81 | }) 82 | }) 83 | }) 84 | 85 | describe('When no content blocks are present on a page', function () { 86 | beforeEach(function () { 87 | loadFixtures('app-c-back-to-top.html') 88 | 89 | contentBlocksComponent = new ContentBlocksComponent() 90 | contentBlocksComponent.toggleHighlight() 91 | 92 | banner = document.querySelector('.govuk-chrome-content-blocks-banner') 93 | }) 94 | 95 | it('shows a message', function () { 96 | expect(banner.innerText).toMatch('No content blocks in use on this page') 97 | }) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /src/components/highlight-component/highlight-component.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | function HighlightComponent () { 4 | this.isComponentsHighlighted = false 5 | this.components = extractComponentsFromPage() 6 | 7 | // Get an array of components on the page. 8 | function extractComponentsFromPage () { 9 | var componentsOnPage = document.querySelectorAll('[class*="app-c"], [class*="gem-c"]') 10 | var componentsOnPageArray = Array.from(componentsOnPage) 11 | return componentsOnPageArray.reduce(function (array, element) { 12 | var componentRegex = /(app-c-|gem-c-)([^ _\n]*(?=[ \n]|$))/ 13 | // Get the value of the components class attribute 14 | var elementClassName = null 15 | if (typeof element.className === 'string') { 16 | elementClassName = element.className 17 | } 18 | // Check if it's an app or gem component 19 | var match = false 20 | 21 | if (elementClassName) { 22 | match = elementClassName.match(componentRegex) 23 | } 24 | 25 | if (match) { 26 | array.push({ 27 | element: element, // 28 | prefix: match[1], // componentType 29 | name: match[2] // componentName 30 | }) 31 | } 32 | 33 | return array 34 | }, []) 35 | } 36 | 37 | // This is looping over the components and for each component in the array it will call the setupComponent method and pass in the component to setup. 38 | this.components.forEach(setupComponent.bind(this)) 39 | 40 | // This method, is going to modify the HTML for each component, it'll set the attribute data-component-name and data-app-name, 41 | function setupComponent (component) { 42 | component.element.setAttribute('data-component-name', component.name) 43 | component.element.setAttribute('data-app-name', component.prefix) 44 | 45 | // the method will add a click event (listener), it'll then open a new window with the documentationUrl for that component. 46 | component.element.addEventListener('click', function (event) { 47 | if (this.isComponentsHighlighted) { 48 | event.stopPropagation() // prevent event bubbling 49 | event.preventDefault() 50 | window.open(Helpers.documentationUrl(component)) 51 | } 52 | }.bind(this)) 53 | } 54 | 55 | chrome.runtime.onMessage.addListener(function (request) { 56 | if (request.trigger === 'toggleComponents') { 57 | this.toggleComponents() 58 | } 59 | }.bind(this)) 60 | } 61 | 62 | HighlightComponent.prototype.toggleComponents = function () { 63 | this.isComponentsHighlighted = !this.isComponentsHighlighted 64 | for (var i = 0; i < this.components.length; i++) { 65 | this.components[i].element.classList.toggle('highlight-component', this.isComponentsHighlighted) 66 | } 67 | 68 | this.sendState() 69 | } 70 | 71 | HighlightComponent.prototype.sendState = function () { 72 | chrome.runtime.sendMessage({ 73 | action: 'highlightState', 74 | highlightState: this.isComponentsHighlighted 75 | }) 76 | } 77 | 78 | var Helpers = { 79 | documentationUrl: function (component) { 80 | if (component.prefix.startsWith('app-c')) { 81 | return 'https://' + this.appHostname() + '.herokuapp.com/component-guide/' + component.name 82 | } else if (component.prefix.startsWith('gem-c')) { 83 | return 'https://govuk-publishing-components.herokuapp.com/component-guide/' + component.name.replace(/-/g, '_') 84 | } 85 | }, 86 | 87 | substitutions: { 88 | collections: 'govuk-collections' 89 | }, 90 | 91 | appHostname: function () { 92 | var renderingElement = document.querySelector('meta[name="govuk:rendering-app"]') 93 | var renderingApp = renderingElement.getAttribute('content') 94 | return this.substitutions[renderingApp] || renderingApp 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /spec/javascripts/content_links.spec.js: -------------------------------------------------------------------------------- 1 | describe('PopupView.generateContentLinks', function () { 2 | var PROD_ENV = { protocol: 'https', serviceDomain: 'publishing.service.gov.uk', origin: 'https://www.gov.uk' } 3 | var DRAFT_PROD_ENV = { protocol: 'https', serviceDomain: 'publishing.service.gov.uk', origin: 'https://draft-origin.publishing.service.gov.uk' } 4 | 5 | it('returns the correct URIs', function () { 6 | var links = Popup.generateContentLinks( 7 | 'https://www.gov.uk/browse/disabilities?foo=bar', 8 | 'https://www.gov.uk', 9 | '/browse/disabilities', 10 | PROD_ENV 11 | ) 12 | 13 | var urls = pluck(links, 'url') 14 | 15 | expect(urls).toEqual([ 16 | 'https://www.gov.uk/browse/disabilities', 17 | 'https://www.gov.uk/api/content/browse/disabilities', 18 | 'https://www.gov.uk/api/search.json?filter_link=/browse/disabilities', 19 | 'https://draft-origin.publishing.service.gov.uk/browse/disabilities', 20 | 'https://support.publishing.service.gov.uk/anonymous_feedback?path=/browse/disabilities', 21 | 'http://webarchive.nationalarchives.gov.uk/*/https://www.gov.uk/browse/disabilities', 22 | 'https://content-data.publishing.service.gov.uk/metrics/browse/disabilities', 23 | 'https://my2.siteimprove.com/QualityAssurance/1054012/Overview/Search?SearchIn=Url&Query=/browse/disabilities', 24 | 'https://search.google.com/structured-data/testing-tool/u/0/#url=https://www.gov.uk/browse/disabilities' 25 | ]) 26 | }) 27 | 28 | it('returns the draft URIs for non-prod environments', function () { 29 | var links = Popup.generateContentLinks( 30 | 'https://www.gov.uk/browse/disabilities?foo=bar', 31 | 'https://www.gov.uk', 32 | '/browse/disabilities', 33 | { protocol: 'https', serviceDomain: 'staging.publishing.service.gov.uk' } 34 | ) 35 | 36 | var urls = pluck(links, 'url') 37 | 38 | expect(urls).toContain( 39 | 'https://draft-origin.staging.publishing.service.gov.uk/browse/disabilities' 40 | ) 41 | }) 42 | 43 | it('does not generate URIs for publishing apps (non-www pages)', function () { 44 | var links = Popup.generateContentLinks( 45 | 'https://search-admin.publishing.service.gov.uk/queries', 46 | 'https://search-admin.publishing.service.gov.uk', 47 | '/queries', 48 | PROD_ENV 49 | ) 50 | 51 | expect(links).toEqual([]) 52 | }) 53 | 54 | it("only generates URLs for publishing-apps when it's the support application", function () { 55 | var links = Popup.generateContentLinks( 56 | 'https://support.publishing.service.gov.uk/anonymous_feedback?path=/browse/disabilities', 57 | 'https://support.publishing.service.gov.uk', 58 | '/anonymous_feedback', 59 | PROD_ENV 60 | ) 61 | 62 | var urls = pluck(links, 'url') 63 | 64 | expect(urls).toEqual([ 65 | 'https://www.gov.uk/browse/disabilities', 66 | 'https://www.gov.uk/api/content/browse/disabilities', 67 | 'https://www.gov.uk/api/search.json?filter_link=/browse/disabilities', 68 | 'https://draft-origin.publishing.service.gov.uk/browse/disabilities', 69 | 'https://support.publishing.service.gov.uk/anonymous_feedback?path=/browse/disabilities', 70 | 'http://webarchive.nationalarchives.gov.uk/*/https://www.gov.uk/browse/disabilities', 71 | 'https://content-data.publishing.service.gov.uk/metrics/browse/disabilities', 72 | 'https://my2.siteimprove.com/QualityAssurance/1054012/Overview/Search?SearchIn=Url&Query=/browse/disabilities', 73 | 'https://search.google.com/structured-data/testing-tool/u/0/#url=https://www.gov.uk/browse/disabilities' 74 | ]) 75 | }) 76 | 77 | it('generates a link for smart answers', function () { 78 | var links = Popup.generateContentLinks( 79 | 'https://www.gov.uk/smart-answer/y/question-1', 80 | 'https://www.gov.uk', 81 | '/smart-answer/y/question-1', 82 | PROD_ENV, 83 | 'smartanswers' 84 | ) 85 | 86 | var urls = pluck(links, 'url') 87 | 88 | expect(urls).toContain('https://www.gov.uk/smart-answer/y/visualise') 89 | }) 90 | 91 | it('generates correct link for content API on draft stack', function () { 92 | var links = Popup.generateContentLinks( 93 | 'https://draft-origin.publishing.service.gov.uk/apply-for-and-manage-a-gov-uk-domain-name', 94 | 'https://draft-origin.publishing.service.gov.uk', 95 | '/apply-for-and-manage-a-gov-uk-domain-name', 96 | DRAFT_PROD_ENV, 97 | 'collections' 98 | ) 99 | 100 | expect(links[1]).toEqual({ 101 | name: 'Content item (JSON)', 102 | url: 'https://draft-origin.publishing.service.gov.uk/api/content/apply-for-and-manage-a-gov-uk-domain-name', 103 | class: '' 104 | }) 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /spec/javascripts/events/ab_bucket_store.spec.js: -------------------------------------------------------------------------------- 1 | /* global abBucketStore */ 2 | 3 | describe('abBucketStore', function () { 4 | it('is initialized empty', function () { 5 | var store = abBucketStore.createStore() 6 | 7 | expect(store.getAll()).toEqual({}) 8 | }) 9 | 10 | describe('addAbTests', function () { 11 | it('does nothing if no tests are added to an already-empty store', function () { 12 | var store = abBucketStore.createStore() 13 | store.addAbTests({}, 'example.com') 14 | 15 | expect(store.getAll('example.com')).toEqual({}) 16 | }) 17 | 18 | it('adds tests to empty store', function () { 19 | var store = abBucketStore.createStore() 20 | store.addAbTests({ 21 | testName1: 'bucket1', 22 | testName2: 'bucket2' 23 | }, 'example.com') 24 | 25 | expect(store.getAll('example.com')).toEqual({ 26 | testName1: 'bucket1', 27 | testName2: 'bucket2' 28 | }) 29 | }) 30 | 31 | it('appends new tests', function () { 32 | var store = abBucketStore.createStore() 33 | store.addAbTests({ 34 | originalTest1: 'originalBucket1', 35 | originalTest2: 'originalBucket2' 36 | }, 'example.com') 37 | 38 | store.addAbTests({ 39 | originalTest1: 'updatedBucket1', 40 | newTest1: 'newBucket1', 41 | originalTest2: 'updatedBucket2', 42 | newTest2: 'newBucket2' 43 | }, 'example.com') 44 | 45 | expect(store.getAll('example.com')).toEqual({ 46 | originalTest1: 'originalBucket1', 47 | originalTest2: 'originalBucket2', 48 | newTest1: 'newBucket1', 49 | newTest2: 'newBucket2' 50 | }) 51 | }) 52 | 53 | it('stores A/B tests by domain name', function () { 54 | var store = abBucketStore.createStore() 55 | store.addAbTests({ 56 | integrationAbTest: 'bucketOnInt' 57 | }, 'www-origin.integration.publishing.service.gov.uk') 58 | store.addAbTests({ 59 | productionAbTest: 'bucketOnProd' 60 | }, 'www.gov.uk') 61 | 62 | expect(store.getAll('www-origin.integration.publishing.service.gov.uk')).toEqual({ 63 | integrationAbTest: 'bucketOnInt' 64 | }) 65 | expect(store.getAll('www.gov.uk')).toEqual({ 66 | productionAbTest: 'bucketOnProd' 67 | }) 68 | }) 69 | }) 70 | 71 | describe('getAll', function () { 72 | it('returns no A/B tests if none have been stored for that domain', function () { 73 | var store = abBucketStore.createStore() 74 | store.addAbTests({ 75 | someAbTest: 'someBucket' 76 | }, 'www.gov.uk') 77 | 78 | expect(store.getAll('example.com')).toEqual({}) 79 | }) 80 | }) 81 | 82 | describe('setBucket', function () { 83 | it('updates value of existing bucket', function () { 84 | var store = abBucketStore.createStore() 85 | store.addAbTests({ 86 | testName1: { 87 | currentBucket: 'originalBucket1', 88 | allowedBuckets: ['originalBucket1', 'updatedBucket'] 89 | }, 90 | testName2: { 91 | currentBucket: 'originalBucket2', 92 | allowedBuckets: ['originalBucket1', 'originalBucket2'] 93 | } 94 | }, 'example.com') 95 | 96 | store.setBucket('testName1', 'updatedBucket', 'example.com') 97 | 98 | expect(store.getAll('example.com')).toEqual({ 99 | testName1: { 100 | currentBucket: 'updatedBucket', 101 | allowedBuckets: ['originalBucket1', 'updatedBucket'] 102 | }, 103 | testName2: { 104 | currentBucket: 'originalBucket2', 105 | allowedBuckets: ['originalBucket1', 'originalBucket2'] 106 | } 107 | }) 108 | }) 109 | 110 | it('updates bucket with matching domain name', function () { 111 | var store = abBucketStore.createStore() 112 | store.addAbTests({ 113 | abTestName: { 114 | currentBucket: 'originalBucket', 115 | allowedBuckets: ['originalBucket', 'updatedBucket'] 116 | } 117 | }, 'www-origin.integration.publishing.service.gov.uk') 118 | store.addAbTests({ 119 | abTestName: { 120 | currentBucket: 'originalBucket', 121 | allowedBuckets: ['originalBucket', 'updatedBucket'] 122 | } 123 | }, 'www.gov.uk') 124 | 125 | store.setBucket('abTestName', 'updatedBucket', 'www.gov.uk') 126 | 127 | expect(store.getAll('www-origin.integration.publishing.service.gov.uk')).toEqual({ 128 | abTestName: { 129 | currentBucket: 'originalBucket', 130 | allowedBuckets: ['originalBucket', 'updatedBucket'] 131 | } 132 | }) 133 | expect(store.getAll('www.gov.uk')).toEqual({ 134 | abTestName: { 135 | currentBucket: 'updatedBucket', 136 | allowedBuckets: ['originalBucket', 'updatedBucket'] 137 | } 138 | }) 139 | }) 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /src/popup/external_links.js: -------------------------------------------------------------------------------- 1 | var Popup = Popup || {} 2 | 3 | // With the content item we can generate a bunch of external links. 4 | Popup.generateExternalLinks = function (contentItem, env) { 5 | // Not all publishing_apps name corresponds to the name of the 6 | // alphagov repo. 7 | function publishingAppNameToRepo (appName) { 8 | var APP_NAMES_TO_REPOS = { 9 | smartanswers: 'smart-answers' 10 | } 11 | 12 | return APP_NAMES_TO_REPOS[appName] || appName 13 | } 14 | 15 | // Not all rendering_apps name corresponds to the name of the 16 | // alphagov repo. 17 | function renderingAppNameToRepo (appName) { 18 | var APP_NAMES_TO_REPOS = { 19 | smartanswers: 'smart-answers', 20 | 'whitehall-frontend': 'whitehall' 21 | } 22 | 23 | return APP_NAMES_TO_REPOS[appName] || appName 24 | } 25 | 26 | var links = [] 27 | 28 | generateEditLink(contentItem, env).forEach(element => { 29 | links.push(element) 30 | }) 31 | 32 | var schemaName = contentItem.schema_name || '' 33 | if (schemaName.indexOf('placeholder') !== -1) { 34 | schemaName = 'placeholder' 35 | } 36 | 37 | links.push({ 38 | name: 'Add tags in content-tagger', 39 | url: env.protocol + '://content-tagger.' + env.serviceDomain + '/content/' + contentItem.content_id 40 | }) 41 | 42 | links.push({ 43 | name: 'Rendering app: ' + contentItem.rendering_app, 44 | url: 'https://docs.publishing.service.gov.uk/apps/' + renderingAppNameToRepo(contentItem.rendering_app) + '.html' 45 | }) 46 | 47 | links.push({ 48 | name: 'Publishing app: ' + contentItem.publishing_app, 49 | url: 'https://docs.publishing.service.gov.uk/apps/' + publishingAppNameToRepo(contentItem.publishing_app) + '.html' 50 | }) 51 | 52 | links.push({ 53 | name: 'Content schema: ' + schemaName, 54 | url: 'https://docs.publishing.service.gov.uk/content-schemas/' + schemaName + '.html' 55 | }) 56 | 57 | links.push({ 58 | name: 'Document type: ' + contentItem.document_type, 59 | url: 'https://docs.publishing.service.gov.uk/document-types/' + contentItem.document_type + '.html' 60 | }) 61 | 62 | return links.filter(function (item) { return item !== undefined }) 63 | } 64 | 65 | function generateEditLink (contentItem, env) { 66 | if (contentItem.document_type === 'topic') { 67 | return [{ 68 | name: 'Edit in collections-publisher', 69 | url: env.protocol + '://collections-publisher.' + env.serviceDomain + '/specialist-sector-pages/' + contentItem.content_id 70 | }] 71 | } else if (contentItem.document_type === 'step_by_step_nav') { 72 | return [{ 73 | name: 'Look up in collections-publisher', 74 | url: env.protocol + '://collections-publisher.' + env.serviceDomain + '/step-by-step-pages' 75 | }] 76 | } else if (contentItem.document_type === 'mainstream_browse_page') { 77 | return [{ 78 | name: 'Edit in collections-publisher', 79 | url: env.protocol + '://collections-publisher.' + env.serviceDomain + '/mainstream-browse-pages/' + contentItem.content_id 80 | }] 81 | } else if (contentItem.document_type === 'taxon') { 82 | return [{ 83 | name: 'Edit in content-tagger', 84 | url: env.protocol + '://content-tagger.' + env.serviceDomain + '/taxons/' + contentItem.content_id 85 | }] 86 | } else if (contentItem.publishing_app === 'publisher') { 87 | return [{ 88 | name: 'Look up in Mainstream Publisher', 89 | url: env.protocol + '://publisher.' + env.serviceDomain + '/by-content-id/' + contentItem.content_id 90 | }] 91 | } else if (contentItem.publishing_app === 'content-publisher') { 92 | return [{ 93 | name: 'Edit in Content Publisher', 94 | url: env.protocol + '://content-publisher.' + env.serviceDomain + '/documents/' + contentItem.content_id + ':' + contentItem.locale 95 | }] 96 | } else if (contentItem.publishing_app === 'whitehall') { 97 | return [{ 98 | name: 'Edit in Whitehall Publisher', 99 | url: env.protocol + '://whitehall-admin.' + env.serviceDomain + '/government/admin/by-content-id/' + contentItem.content_id 100 | }] 101 | } else if (contentItem.document_type === 'manual') { 102 | return [{ 103 | name: 'Edit in Manuals Publisher', 104 | url: env.protocol + '://manuals-publisher.' + env.serviceDomain + '/manuals/' + contentItem.content_id 105 | }] 106 | } else if (contentItem.publishing_app === 'specialist-publisher') { 107 | return [{ 108 | name: 'Edit in Specialist Publisher', 109 | url: env.protocol + '://specialist-publisher.' + env.serviceDomain + '/' + contentItem.document_type.replace(/_/g, '-') + 's/' + contentItem.content_id 110 | }] 111 | } else if (contentItem.publishing_app === 'smartanswers') { 112 | return [ 113 | { 114 | name: 'View flow page in Github', 115 | url: 'https://github.com/alphagov/smart-answers/tree/main/app/flows' + contentItem.base_path.replace(/-/g, '_') + '_flow.rb' 116 | }, 117 | { 118 | name: 'View flow details folder in Github', 119 | url: 'https://github.com/alphagov/smart-answers/tree/main/app/flows' + contentItem.base_path.replace(/-/g, '_') + '_flow' 120 | } 121 | ] 122 | } else { 123 | return [] 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /spec/javascripts/highlight_component.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* global HighlightComponent loadFixtures spyOnEvent setFixtures Helpers */ 3 | 4 | describe('Toggling component highlighting', function () { 5 | var breadcrumbsElement 6 | var highlightComponent 7 | 8 | beforeEach(function () { 9 | loadFixtures('gem-c-breadcrumbs.html') 10 | 11 | // Mock addListener function to call toggleComponents trigger when initialized 12 | window.chrome = { 13 | runtime: { 14 | onMessage: { 15 | addListener: function (callback) { 16 | /* eslint-disable-next-line */ 17 | callback({ trigger: 'toggleComponents' }) 18 | } 19 | }, 20 | sendMessage: function () {} 21 | } 22 | } 23 | 24 | highlightComponent = new HighlightComponent() 25 | 26 | breadcrumbsElement = document.querySelector('#jasmine-fixtures .gem-c-breadcrumbs') 27 | }) 28 | 29 | it('highlights govuk components', function () { 30 | expect(breadcrumbsElement).toHaveClass('highlight-component') 31 | }) 32 | 33 | it('exposes the component name as data attribute', function () { 34 | expect(breadcrumbsElement.dataset.componentName).toEqual('breadcrumbs') 35 | }) 36 | 37 | it('exposes the app name as data attribute', function () { 38 | expect(breadcrumbsElement.dataset.appName).toEqual('gem-c-') 39 | }) 40 | 41 | it("adds the ability to click through to the component's documentation", function () { 42 | spyOn(window, 'open').and.callThrough() 43 | var clickEvent = spyOnEvent(breadcrumbsElement, 'click') 44 | 45 | breadcrumbsElement.click() 46 | 47 | expect(clickEvent).toHaveBeenTriggered() 48 | expect(window.open).toHaveBeenCalledWith( 49 | 'https://govuk-publishing-components.herokuapp.com/component-guide/breadcrumbs' 50 | ) 51 | }) 52 | 53 | it('removes the highlight when toggled off', function () { 54 | highlightComponent.toggleComponents() 55 | 56 | expect(breadcrumbsElement).not.toHaveClass('highlight-component') 57 | }) 58 | 59 | it('removes the click functionality when toggled off', function () { 60 | spyOn(window, 'open').and.callThrough() 61 | highlightComponent.toggleComponents() 62 | 63 | var clickEvent = spyOnEvent(breadcrumbsElement, 'click') 64 | 65 | breadcrumbsElement.click() 66 | 67 | expect(clickEvent).toHaveBeenTriggered() 68 | expect(window.open).not.toHaveBeenCalled() 69 | }) 70 | }) 71 | 72 | describe('highlightComponent', function () { 73 | beforeEach(function () { 74 | window.chrome = { 75 | runtime: { 76 | onMessage: { 77 | addListener: function (callback) { } 78 | }, 79 | sendMessage: function () {} 80 | } 81 | } 82 | }) 83 | 84 | describe('components', function () { 85 | var html 86 | 87 | beforeEach(function () { 88 | loadFixtures( 89 | 'app-c-back-to-top.html', 90 | 'gem-c-breadcrumbs.html', 91 | 'gem-c-button.html', 92 | 'gem-c-label.html' 93 | ) 94 | 95 | html = document.querySelector('#jasmine-fixtures') 96 | }) 97 | 98 | it('builds an array of components', function () { 99 | var highlightComponent = new HighlightComponent() 100 | 101 | expect(highlightComponent.components).toEqual( 102 | [ 103 | { 104 | name: 'back-to-top', 105 | prefix: 'app-c-', 106 | element: html.querySelector('.app-c-back-to-top') 107 | }, 108 | { 109 | name: 'breadcrumbs', 110 | prefix: 'gem-c-', 111 | element: html.querySelector('.gem-c-breadcrumbs') 112 | }, 113 | { 114 | name: 'button', 115 | prefix: 'gem-c-', 116 | element: html.querySelector('.gem-c-button') 117 | }, 118 | { 119 | name: 'label', 120 | prefix: 'gem-c-', 121 | element: html.querySelector('.gem-c-label') 122 | } 123 | ] 124 | ) 125 | }) 126 | }) 127 | 128 | describe('toggleComponents', function () { 129 | it('toggles the internal state', function () { 130 | var highlightComponent = new HighlightComponent() 131 | 132 | expect(highlightComponent.isComponentsHighlighted).toEqual(false) 133 | 134 | highlightComponent.toggleComponents() 135 | expect(highlightComponent.isComponentsHighlighted).toEqual(true) 136 | 137 | highlightComponent.toggleComponents() 138 | expect(highlightComponent.isComponentsHighlighted).toEqual(false) 139 | }) 140 | 141 | it('toggles the highlight-component class', function () { 142 | loadFixtures('gem-c-button.html') 143 | 144 | var highlightComponent = new HighlightComponent() 145 | 146 | var buttonElement = document.querySelector('#jasmine-fixtures .gem-c-button') 147 | expect(buttonElement).not.toHaveClass('highlight-component') 148 | 149 | highlightComponent.toggleComponents() 150 | expect(buttonElement).toHaveClass('highlight-component') 151 | 152 | highlightComponent.toggleComponents() 153 | expect(buttonElement).not.toHaveClass('highlight-component') 154 | }) 155 | }) 156 | }) 157 | 158 | describe('Helpers.documentationUrl', function () { 159 | it("creates the correct URL for 'app' components with substitution", function () { 160 | setFixtures('') 161 | Helpers.substitutions = { 162 | collections: 'another_host' 163 | } 164 | expect( 165 | Helpers.documentationUrl({ 166 | prefix: 'app-c', 167 | name: 'back-to-top' 168 | }) 169 | ).toEqual( 170 | 'https://another_host.herokuapp.com/component-guide/back-to-top' 171 | ) 172 | }) 173 | 174 | it("creates the correct URL for 'app' components without substitution", function () { 175 | setFixtures('') 176 | Helpers.substitutions = { 177 | collections: 'another_host' 178 | } 179 | expect( 180 | Helpers.documentationUrl({ 181 | prefix: 'app-c', 182 | name: 'back-to-top' 183 | }) 184 | ).toEqual( 185 | 'https://rendering_app.herokuapp.com/component-guide/back-to-top' 186 | ) 187 | }) 188 | 189 | it("creates the correct URL for 'gem' components", function () { 190 | setFixtures('') 191 | Helpers.substitutions = { 192 | collections: 'another_host' 193 | } 194 | expect( 195 | Helpers.documentationUrl({ 196 | prefix: 'gem-c', 197 | name: 'label' 198 | }) 199 | ).toEqual( 200 | 'https://govuk-publishing-components.herokuapp.com/component-guide/label' 201 | ) 202 | }) 203 | }) 204 | -------------------------------------------------------------------------------- /spec/javascripts/external_links.spec.js: -------------------------------------------------------------------------------- 1 | describe('Popup.generateExternalLinks', function () { 2 | var PROD_ENV = { protocol: 'https', serviceDomain: 'publishing.service.gov.uk' } 3 | 4 | it('generates a link to the rendering app GitHub', function () { 5 | var contentItem = { 6 | rendering_app: 'collections' 7 | } 8 | 9 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 10 | 11 | expect(links).toContain({ 12 | name: 'Rendering app: collections', 13 | url: 'https://docs.publishing.service.gov.uk/apps/collections.html' 14 | }) 15 | }) 16 | 17 | it('generates a Github link when the rendering app does not match the repository name', function () { 18 | var contentItem = { 19 | rendering_app: 'smartanswers', 20 | base_path: '/my-smart-answer' 21 | } 22 | 23 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 24 | 25 | expect(links).toContain({ 26 | name: 'Rendering app: smartanswers', 27 | url: 'https://docs.publishing.service.gov.uk/apps/smart-answers.html' 28 | }) 29 | }) 30 | 31 | it('generates a link to the publishing app in the docs', function () { 32 | var contentItem = { 33 | publishing_app: 'collections-publisher' 34 | } 35 | 36 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 37 | 38 | expect(links).toContain({ 39 | name: 'Publishing app: collections-publisher', 40 | url: 'https://docs.publishing.service.gov.uk/apps/collections-publisher.html' 41 | }) 42 | }) 43 | 44 | it('generates the correct docs link when a publishing app does not match the repository name', function () { 45 | var contentItem = { 46 | publishing_app: 'smartanswers', 47 | base_path: '/my-smart-answer' 48 | } 49 | 50 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 51 | 52 | expect(links).toContain({ 53 | name: 'Publishing app: smartanswers', 54 | url: 'https://docs.publishing.service.gov.uk/apps/smart-answers.html' 55 | }) 56 | }) 57 | 58 | it('generates a link to the content schema', function () { 59 | var contentItem = { 60 | schema_name: 'topic' 61 | } 62 | 63 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 64 | 65 | expect(links).toContain({ 66 | name: 'Content schema: topic', 67 | url: 'https://docs.publishing.service.gov.uk/content-schemas/topic.html' 68 | }) 69 | }) 70 | 71 | it('correctly links to placeholder schemas', function () { 72 | var contentItem = { 73 | schema_name: 'placeholder_something_or_other' 74 | } 75 | 76 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 77 | 78 | expect(links).toContain({ 79 | name: 'Content schema: placeholder', 80 | url: 'https://docs.publishing.service.gov.uk/content-schemas/placeholder.html' 81 | }) 82 | }) 83 | 84 | it('correctly links to document types', function () { 85 | var contentItem = { 86 | document_type: 'announcement' 87 | } 88 | 89 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 90 | 91 | expect(links).toContain({ 92 | name: 'Document type: announcement', 93 | url: 'https://docs.publishing.service.gov.uk/document-types/announcement.html' 94 | }) 95 | }) 96 | 97 | it('generates edit links for topics', function () { 98 | var contentItem = { 99 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4', 100 | document_type: 'topic' 101 | } 102 | 103 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 104 | 105 | expect(links).toContain({ 106 | name: 'Edit in collections-publisher', 107 | url: 'https://collections-publisher.publishing.service.gov.uk/specialist-sector-pages/4d8568c4-67f2-48da-a578-5ac6f35b69b4' 108 | }) 109 | }) 110 | 111 | it('generates edit links for mainstream browse pages', function () { 112 | var contentItem = { 113 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4', 114 | document_type: 'mainstream_browse_page' 115 | } 116 | 117 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 118 | 119 | expect(links).toContain({ 120 | name: 'Edit in collections-publisher', 121 | url: 'https://collections-publisher.publishing.service.gov.uk/mainstream-browse-pages/4d8568c4-67f2-48da-a578-5ac6f35b69b4' 122 | }) 123 | }) 124 | 125 | it('generates edit links for step by steps', function () { 126 | var contentItem = { 127 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4', 128 | document_type: 'step_by_step_nav' 129 | } 130 | 131 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 132 | 133 | expect(links).toContain({ 134 | name: 'Look up in collections-publisher', 135 | url: 'https://collections-publisher.publishing.service.gov.uk/step-by-step-pages' 136 | }) 137 | }) 138 | 139 | it('generates edit links for mainstream items', function () { 140 | var contentItem = { 141 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4', 142 | publishing_app: 'publisher', 143 | base_path: '/certifying-a-document' 144 | } 145 | 146 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 147 | 148 | expect(links).toContain({ 149 | name: 'Look up in Mainstream Publisher', 150 | url: 'https://publisher.publishing.service.gov.uk/by-content-id/4d8568c4-67f2-48da-a578-5ac6f35b69b4' 151 | }) 152 | }) 153 | 154 | it('generates edit links for Whitehall items', function () { 155 | var contentItem = { 156 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4', 157 | publishing_app: 'whitehall' 158 | } 159 | 160 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 161 | 162 | expect(links).toContain({ 163 | name: 'Edit in Whitehall Publisher', 164 | url: 'https://whitehall-admin.publishing.service.gov.uk/government/admin/by-content-id/4d8568c4-67f2-48da-a578-5ac6f35b69b4' 165 | }) 166 | }) 167 | 168 | it('generates edit links for Specalist Publisher items', function () { 169 | var contentItem = { 170 | publishing_app: 'specialist-publisher', 171 | content_id: '4dd888e6-e890-4498-9913-b89e4e5a0059', 172 | document_type: 'business_finance_support_scheme' 173 | } 174 | 175 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 176 | 177 | expect(links).toContain({ 178 | name: 'Edit in Specialist Publisher', 179 | url: 'https://specialist-publisher.publishing.service.gov.uk/business-finance-support-schemes/4dd888e6-e890-4498-9913-b89e4e5a0059' 180 | }) 181 | }) 182 | 183 | it('generates edit links for Content Publisher items', function () { 184 | var contentItem = { 185 | publishing_app: 'content-publisher', 186 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4', 187 | locale: 'cy' 188 | } 189 | 190 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 191 | 192 | expect(links).toContain({ 193 | name: 'Edit in Content Publisher', 194 | url: 'https://content-publisher.publishing.service.gov.uk/documents/4d8568c4-67f2-48da-a578-5ac6f35b69b4:cy' 195 | }) 196 | }) 197 | 198 | it('includes a link to content-tagger', function () { 199 | var contentItem = { 200 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4' 201 | } 202 | 203 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 204 | 205 | expect(links).toContain({ 206 | name: 'Add tags in content-tagger', 207 | url: 'https://content-tagger.publishing.service.gov.uk/content/4d8568c4-67f2-48da-a578-5ac6f35b69b4' 208 | }) 209 | }) 210 | 211 | it('includes an edit link to content-tagger', function () { 212 | var contentItem = { 213 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4', 214 | document_type: 'taxon' 215 | } 216 | 217 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 218 | 219 | expect(links).toContain({ 220 | name: 'Edit in content-tagger', 221 | url: 'https://content-tagger.publishing.service.gov.uk/taxons/4d8568c4-67f2-48da-a578-5ac6f35b69b4' 222 | }) 223 | }) 224 | }) 225 | -------------------------------------------------------------------------------- /src/popup/lib/mustache.min.js: -------------------------------------------------------------------------------- 1 | (function defineMustache(global,factory){if(typeof exports==="object"&&exports&&typeof exports.nodeName!=="string"){factory(exports)}else if(typeof define==="function"&&define.amd){define(["exports"],factory)}else{global.Mustache={};factory(Mustache)}})(this,function mustacheFactory(mustache){var objectToString=Object.prototype.toString;var isArray=Array.isArray||function isArrayPolyfill(object){return objectToString.call(object)==="[object Array]"};function isFunction(object){return typeof object==="function"}function typeStr(obj){return isArray(obj)?"array":typeof obj}function escapeRegExp(string){return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g,"\\$&")}function hasProperty(obj,propName){return obj!=null&&typeof obj==="object"&&propName in obj}var regExpTest=RegExp.prototype.test;function testRegExp(re,string){return regExpTest.call(re,string)}var nonSpaceRe=/\S/;function isWhitespace(string){return!testRegExp(nonSpaceRe,string)}var entityMap={"&":"&","<":"<",">":">",'"':""","'":"'","/":"/"};function escapeHtml(string){return String(string).replace(/[&<>"'\/]/g,function fromEntityMap(s){return entityMap[s]})}var whiteRe=/\s*/;var spaceRe=/\s+/;var equalsRe=/\s*=/;var curlyRe=/\s*\}/;var tagRe=/#|\^|\/|>|\{|&|=|!/;function parseTemplate(template,tags){if(!template)return[];var sections=[];var tokens=[];var spaces=[];var hasTag=false;var nonSpace=false;function stripSpace(){if(hasTag&&!nonSpace){while(spaces.length)delete tokens[spaces.pop()]}else{spaces=[]}hasTag=false;nonSpace=false}var openingTagRe,closingTagRe,closingCurlyRe;function compileTags(tagsToCompile){if(typeof tagsToCompile==="string")tagsToCompile=tagsToCompile.split(spaceRe,2);if(!isArray(tagsToCompile)||tagsToCompile.length!==2)throw new Error("Invalid tags: "+tagsToCompile);openingTagRe=new RegExp(escapeRegExp(tagsToCompile[0])+"\\s*");closingTagRe=new RegExp("\\s*"+escapeRegExp(tagsToCompile[1]));closingCurlyRe=new RegExp("\\s*"+escapeRegExp("}"+tagsToCompile[1]))}compileTags(tags||mustache.tags);var scanner=new Scanner(template);var start,type,value,chr,token,openSection;while(!scanner.eos()){start=scanner.pos;value=scanner.scanUntil(openingTagRe);if(value){for(var i=0,valueLength=value.length;i