diff --git a/public/_/readthedocs-addons.json b/public/_/readthedocs-addons.json index 81e3ec3f..d5009fc8 100644 --- a/public/_/readthedocs-addons.json +++ b/public/_/readthedocs-addons.json @@ -93,6 +93,9 @@ "readthedocs": { "analytics": { "code": "UA-12345" + }, + "resolver": { + "filename": "/index.html" } }, "addons": { diff --git a/public/index.html b/public/index.html index 8d786473..06c81277 100644 --- a/public/index.html +++ b/public/index.html @@ -22,6 +22,9 @@

Documentation Addons

CustomEvent

Project slug using CustomEvent:

+

Flyout

+ +

DocDiff

Visit this page to take a look at it.

diff --git a/src/analytics.js b/src/analytics.js index 447989a5..527e9cd8 100644 --- a/src/analytics.js +++ b/src/analytics.js @@ -3,8 +3,7 @@ import { default as fetch } from "unfetch"; import { ajv } from "./data-validation"; -import { AddonBase } from "./utils"; -import { CLIENT_VERSION } from "./utils"; +import { AddonBase, CLIENT_VERSION } from "./utils"; export const API_ENDPOINT = "/_/api/v2/analytics/"; @@ -27,8 +26,7 @@ export class AnalyticsAddon extends AddonBase { static addonName = "Analytics"; static enabledOnHttpStatus = [200, 404]; - constructor(config) { - super(); + loadConfig(config) { this.config = config; // Only register pageviews on non-external versions diff --git a/src/application.js b/src/application.js new file mode 100644 index 00000000..55ed801b --- /dev/null +++ b/src/application.js @@ -0,0 +1,174 @@ +import { CSSResult } from "lit"; + +import { + docTool, + domReady, + isEmbedded, + IS_PRODUCTION, + setupLogging, + setupHistoryEvents, + getMetadataValue, +} from "./utils"; +import { getReadTheDocsConfig } from "./readthedocs-config"; +import { + EVENT_READTHEDOCS_ADDONS_INTERNAL_DATA_READY, + EVENT_READTHEDOCS_URL_CHANGED, +} from "./events"; + +import * as notification from "./notification"; +import * as analytics from "./analytics"; +import * as search from "./search"; +import * as docdiff from "./docdiff"; +import * as flyout from "./flyout"; +import * as ethicalads from "./ethicalads"; +import * as hotkeys from "./hotkeys"; +import * as linkpreviews from "./linkpreviews"; +import * as filetreediff from "./filetreediff"; +import * as customscript from "./customscript"; +import * as application from "./application"; +import { default as objectPath } from "object-path"; + +import doctoolsStyleSheet from "./doctools.css"; + +export class AddonsApplication { + constructor() { + setupLogging(); + setupHistoryEvents(); + + this.addonsInstances = []; + this.config = null; + + console.debug( + "Addons Application config (from constructor() method)", + this.config, + ); + + this.addons = [ + flyout.FlyoutAddon, + notification.NotificationAddon, + analytics.AnalyticsAddon, + ethicalads.EthicalAdsAddon, + search.SearchAddon, + + // HotKeys & FileTreeDiff have to be initialized before DocDiff because when + // `?readthedocs-diff=true` DocDiff triggers an event that HotKeys has to + // listen to to update its internal state. + // https://github.com/readthedocs/addons/blob/47645b013724cdf244716b549a5baa28409fafcb/src/docdiff.js#L105-L111 + hotkeys.HotKeysAddon, + filetreediff.FileTreeDiffAddon, + docdiff.DocDiffAddon, + + linkpreviews.LinkPreviewsAddon, + customscript.CustomScriptAddon, + ]; + + this.httpStatus = getMetadataValue("readthedocs-http-status"); + + this.addDoctoolData(); + getReadTheDocsConfig(this.sendUrlParam()); + } + + reload(config) { + console.debug("Addons Application config (from reload() method)", config); + + if (!config) { + return; + } + this.config = config; + if (this.config && !this.loadWhenEmbedded()) { + return; + } + + if (!this.addonsInstances.length) { + // Addons instances were not created yet + try { + this.addonsInstances = this.addons + .filter((addon) => addon.isEnabled(this.config, this.httpStatus)) + .map((addon) => new addon(this.config)); + } catch (err) { + console.error(err); + } + } else { + // Addons instances were already created. We just need to reload them with + // the new config object. + for (const addon of this.addonsInstances) { + addon.loadConfig(config); + } + } + return; + } + + loadWhenEmbedded() { + const loadWhenEmbedded = objectPath.get( + this.config, + "addons.options.load_when_embedded", + false, + ); + if (isEmbedded() && !loadWhenEmbedded) { + return false; + } + return true; + } + + sendUrlParam() { + for (const addon of this.addons) { + if (addon.requiresUrlParam()) { + console.debug(`${addon.addonName} requires "url=" parameter.`); + return true; + } + } + return false; + } + + addDoctoolData() { + // Apply fixes to variables for individual documentation tools + const elementHtml = document.querySelector("html"); + if (elementHtml) { + // Inject styles at the parent DOM to set variables at :root + let styleSheet = doctoolsStyleSheet; + if (doctoolsStyleSheet instanceof CSSResult) { + styleSheet = doctoolsStyleSheet.styleSheet; + } + document.adoptedStyleSheets = [styleSheet]; + + // If we detect a documentation tool, set attributes on :root to allow + // for CSS selectors to utilize these values. + if (docTool.documentationTool) { + elementHtml.setAttribute( + "data-readthedocs-tool", + docTool.documentationTool, + ); + } + if (docTool.documentationTheme) { + elementHtml.setAttribute( + "data-readthedocs-tool-theme", + docTool.documentationTheme, + ); + } + } + } +} + +export const addonsApplication = new AddonsApplication(); + +/** + * Subscribe to `EVENT_READTHEDOCS_ADDONS_INTERNAL_DATA_READY` to reload all the + * addons with fresh Addons API data once it's ready. + * + */ +document.addEventListener( + EVENT_READTHEDOCS_ADDONS_INTERNAL_DATA_READY, + (event) => { + addonsApplication.reload(event.detail.data(true)); + }, +); + +/** + * Subscribe to `EVENT_READTHEDOCS_URL_CHANGED` to trigger a new request to + * Addons API to fetch fresh data. + * + */ +window.addEventListener(EVENT_READTHEDOCS_URL_CHANGED, (event) => { + console.debug("URL Change detected. Triggering a new API call", event); + getReadTheDocsConfig(addonsApplication.sendUrlParam()); +}); diff --git a/src/customscript.js b/src/customscript.js index bdb594c3..f0884054 100644 --- a/src/customscript.js +++ b/src/customscript.js @@ -15,8 +15,7 @@ export class CustomScriptAddon extends AddonBase { static addonName = "CustomScript"; static enabledOnHttpStatus = [200, 403, 404, 500]; - constructor(config) { - super(); + loadConfig(config) { this.config = config; if (objectPath.get(this.config, "addons.customscript.src")) { @@ -25,6 +24,11 @@ export class CustomScriptAddon extends AddonBase { } injectJavaScriptFile() { + // Do not add the script if it already exists in the page. + if (document.querySelector(`#${SCRIPT_ID}`) !== null) { + return; + } + const script = document.createElement("script"); script.id = SCRIPT_ID; script.src = objectPath.get(this.config, "addons.customscript.src"); diff --git a/src/data-validation.js b/src/data-validation.js index 97925c4b..1db59f9d 100644 --- a/src/data-validation.js +++ b/src/data-validation.js @@ -131,7 +131,7 @@ const addons_ethicalads = { const addons_flyout = { $id: "http://v1.schemas.readthedocs.org/addons.flyout.json", type: "object", - required: ["addons", "projects", "versions"], + required: ["addons", "projects", "versions", "readthedocs"], properties: { addons: { type: "object", @@ -242,6 +242,19 @@ const addons_flyout = { }, }, }, + readthedocs: { + type: "object", + required: ["resolver"], + properties: { + resolver: { + type: "object", + required: ["filename"], + properties: { + filename: { type: "string" }, + }, + }, + }, + }, }, }; @@ -249,7 +262,7 @@ const addons_flyout = { const addons_filetreediff = { $id: "http://v1.schemas.readthedocs.org/addons.filetreediff.json", type: "object", - required: ["addons"], + required: ["addons", "versions"], properties: { addons: { type: "object", @@ -272,6 +285,19 @@ const addons_filetreediff = { }, }, }, + versions: { + type: "object", + required: ["current"], + properties: { + current: { + type: "object", + required: ["type"], + properties: { + type: { type: "string" }, + }, + }, + }, + }, }, }; @@ -317,7 +343,7 @@ const addons_hotkeys = { const addons_notifications = { $id: "http://v1.schemas.readthedocs.org/addons.notifications.json", type: "object", - required: ["addons"], + required: ["addons", "readthedocs"], properties: { addons: { type: "object", @@ -429,6 +455,19 @@ const addons_notifications = { }, }, }, + readthedocs: { + type: "object", + required: ["resolver"], + properties: { + resolver: { + type: "object", + required: ["filename"], + properties: { + filename: { type: "string" }, + }, + }, + }, + }, }, }; diff --git a/src/docdiff.js b/src/docdiff.js index 36994f5b..d2627cbf 100644 --- a/src/docdiff.js +++ b/src/docdiff.js @@ -11,7 +11,6 @@ import docdiffGeneralStyleSheet from "./docdiff.document.css"; // See https://github.com/readthedocs/addons/pull/234 import * as visualDomDiff from "visual-dom-diff"; -import { AddonBase } from "./utils"; import { EVENT_READTHEDOCS_DOCDIFF_ADDED_REMOVED_SHOW, EVENT_READTHEDOCS_DOCDIFF_HIDE, @@ -19,7 +18,7 @@ import { } from "./events"; import { nothing, LitElement } from "lit"; import { default as objectPath } from "object-path"; -import { getQueryParam, docTool } from "./utils"; +import { AddonBase, getQueryParam, docTool } from "./utils"; import { EMBED_API_ENDPOINT } from "./constants"; export const DOCDIFF_URL_PARAM = "readthedocs-diff"; @@ -266,23 +265,7 @@ export class DocDiffAddon extends AddonBase { "http://v1.schemas.readthedocs.org/addons.docdiff.json"; static addonEnabledPath = "addons.doc_diff.enabled"; static addonName = "DocDiff"; - - constructor(config) { - super(); - - // TODO: is it possible to move this `constructor` to the `AddonBase` class? - customElements.define("readthedocs-docdiff", DocDiffElement); - let elems = document.querySelectorAll("readthedocs-docdiff"); - if (!elems.length) { - elems = [new DocDiffElement()]; - document.body.append(elems[0]); - elems[0].requestUpdate(); - } - - for (const elem of elems) { - elem.loadConfig(config); - } - } + static elementClass = DocDiffElement; static requiresUrlParam() { return ( @@ -294,3 +277,5 @@ export class DocDiffAddon extends AddonBase { ); } } + +customElements.define(DocDiffElement.elementName, DocDiffElement); diff --git a/src/ethicalads.js b/src/ethicalads.js index 11a02e7a..1327442c 100644 --- a/src/ethicalads.js +++ b/src/ethicalads.js @@ -31,9 +31,14 @@ export class EthicalAdsAddon extends AddonBase { static addonEnabledPath = "addons.ethicalads.enabled"; static addonName = "EthicalAds"; - constructor(config) { - super(); + loadConfig(config) { this.config = config; + + // Do not add another ad if we already added one + if (document.querySelector(`#${AD_SCRIPT_ID}`) !== null) { + return; + } + this.injectEthicalAds(); } diff --git a/src/events.js b/src/events.js index 02cba032..c0ded28e 100644 --- a/src/events.js +++ b/src/events.js @@ -14,14 +14,23 @@ export const EVENT_READTHEDOCS_FLYOUT_HIDE = "readthedocs-flyout-hide"; export const EVENT_READTHEDOCS_URL_CHANGED = "readthedocs-url-changed"; /** - * Event triggered when the Read the Docs data is ready to be consumed. + * Event triggered when the Read the Docs data is ready to be consumed by users. * * This is the event users subscribe to to make usage of Read the Docs data. * The object received is `ReadTheDocsEventData`. */ -export const EVENT_READTHEDOCS_ADDONS_DATA_READY = +export const EVENT_READTHEDOCS_ADDONS_USER_DATA_READY = "readthedocs-addons-data-ready"; +/** + * Event triggered when the Read the Docs data is ready to be consumed internally. + * + * This is the event users subscribe to to make usage of Read the Docs data. + * The object received is `ReadTheDocsEventData`. + */ +export const EVENT_READTHEDOCS_ADDONS_INTERNAL_DATA_READY = + "readthedocs-addons-internal-data-ready"; + /** * Event triggered when any addons modifies the root DOM. * @@ -33,7 +42,7 @@ export const EVENT_READTHEDOCS_ROOT_DOM_CHANGED = "readthedocs-root-dom-changed"; /** - * Object to pass to user subscribing to `EVENT_READTHEDOCS_ADDONS_DATA_READY`. + * Object to pass to user subscribing to `EVENT_READTHEDOCS_ADDONS_*_DATA_READY`. * * This object allows us to have a better communication with the user. * Instead of passing the raw data, we pass this object and enforce them @@ -61,14 +70,15 @@ export class ReadTheDocsEventData { "readthedocs-addons-api-version", ); if (metadataAddonsAPIVersion === undefined) { - throw `Subscribing to '${EVENT_READTHEDOCS_ADDONS_DATA_READY}' requires defining the '' tag in the HTML.`; + throw `Subscribing to '${EVENT_READTHEDOCS_ADDONS_USER_DATA_READY}' requires defining the '' tag in the HTML.`; } this._initialized = true; } - data() { - if (!this._initialized) { + data(internal) { + // ``internal`` is used internally, to skip the META element check. + if (!this._initialized && !internal) { this.initialize(); } return this._data; diff --git a/src/filetreediff.js b/src/filetreediff.js index 9f701c8e..e14d4868 100644 --- a/src/filetreediff.js +++ b/src/filetreediff.js @@ -329,10 +329,9 @@ export class FileTreeDiffElement extends LitElement { /** * File Tree Diff addon * - * UNDER DEVELOPMENT. - * - * Currently, this addon shows in the console all the file changed compared to - * the LATEST version of the project. + * This addon shows a small UI element at the top-right with a selector listing + * all the "Added" and "Modified" files compared to the base version + * (configurable from project's setting in the WebUI). * * @param {Object} config - Addon configuration object */ @@ -341,22 +340,14 @@ export class FileTreeDiffAddon extends AddonBase { "http://v1.schemas.readthedocs.org/addons.filetreediff.json"; static addonEnabledPath = "addons.filetreediff.enabled"; static addonName = "File Tree Diff"; + static elementClass = FileTreeDiffElement; - constructor(config) { - super(); - this.config = config; - - let elems = document.querySelectorAll("readthedocs-filetreediff"); - if (!elems.length) { - elems = [new FileTreeDiffElement()]; - document.body.append(elems[0]); - elems[0].requestUpdate(); - } - - for (const elem of elems) { - elem.loadConfig(config); - } + static isEnabled(config, httpStatus) { + return ( + super.isEnabled(config, httpStatus) && + config.versions.current.type === "external" + ); } } -customElements.define("readthedocs-filetreediff", FileTreeDiffElement); +customElements.define(FileTreeDiffElement.elementName, FileTreeDiffElement); diff --git a/src/flyout.js b/src/flyout.js index 45c13ec2..2a92c1e9 100644 --- a/src/flyout.js +++ b/src/flyout.js @@ -12,7 +12,12 @@ import { classMap } from "lit/directives/class-map.js"; import { default as objectPath } from "object-path"; import styleSheet from "./flyout.css"; -import { AddonBase, addUtmParameters, getLinkWithFilename } from "./utils"; +import { + AddonBase, + addUtmParameters, + getLinkWithFilename, + docTool, +} from "./utils"; import { SPHINX, MKDOCS_MATERIAL } from "./constants"; import { EVENT_READTHEDOCS_SEARCH_SHOW, @@ -278,7 +283,10 @@ export class FlyoutElement extends LitElement { } const getVersionLink = (version) => { - const url = getLinkWithFilename(version.urls.documentation); + const url = getLinkWithFilename( + version.urls.documentation, + this.config.readthedocs.resolver.filename, + ); const link = html`${version.slug}`; return this.config.versions.current.slug == version.slug ? html`${link}` @@ -301,7 +309,10 @@ export class FlyoutElement extends LitElement { } const getLanguageLink = (translation) => { - const url = getLinkWithFilename(translation.urls.documentation); + const url = getLinkWithFilename( + translation.urls.documentation, + this.config.readthedocs.resolver.filename, + ); const link = html`${translation.language.code}`; return this.config.projects.current.slug === translation.slug ? html`${link}` @@ -396,25 +407,18 @@ export class FlyoutAddon extends AddonBase { "http://v1.schemas.readthedocs.org/addons.flyout.json"; static addonEnabledPath = "addons.flyout.enabled"; static addonName = "Flyout"; - - constructor(config) { - super(); - - // If there are no elements found, inject one - let elems = document.querySelectorAll("readthedocs-flyout"); - if (!elems.length) { - elems = [new FlyoutElement()]; - - // We cannot use `render(elems[0], document.body)` because there is a race conditions between all the addons. - // So, we append the web-component first and then request an update of it. - document.body.append(elems[0]); - elems[0].requestUpdate(); - } - - for (const elem of elems) { - elem.loadConfig(config); - } + static elementClass = FlyoutElement; + + static requiresUrlParam() { + // Flyout requires URL param for the feature "keep the same page when + // switching version". We need to know the URL path + // (``readthedocs.resolver.filename`` from the API or MEATA + // ``readthedocs-resolver-filename``) to be able to generate those URLs. + // + // NOTE: If we ever make this feature configurable and user disables it, we + // can adapt this code to return ``false`` in that case. + return docTool.isSinglePageApplication(); } } -customElements.define("readthedocs-flyout", FlyoutElement); +customElements.define(FlyoutElement.elementName, FlyoutElement); diff --git a/src/hotkeys.js b/src/hotkeys.js index f6ff1e92..f5d3b372 100644 --- a/src/hotkeys.js +++ b/src/hotkeys.js @@ -123,22 +123,7 @@ export class HotKeysAddon extends AddonBase { "http://v1.schemas.readthedocs.org/addons.hotkeys.json"; static addonEnabledPath = "addons.hotkeys.enabled"; static addonName = "HotKeys"; - - constructor(config) { - super(); - - // TODO: is it possible to move this `constructor` to the `AddonBase` class? - let elems = document.querySelectorAll("readthedocs-hotkeys"); - if (!elems.length) { - elems = [new HotKeysElement()]; - document.body.append(elems[0]); - elems[0].requestUpdate(); - } - - for (const elem of elems) { - elem.loadConfig(config); - } - } + static elementClass = HotKeysElement; } -customElements.define("readthedocs-hotkeys", HotKeysElement); +customElements.define(HotKeysElement.elementName, HotKeysElement); diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 6e3d7fc3..00000000 --- a/src/index.js +++ /dev/null @@ -1,125 +0,0 @@ -import { CSSResult } from "lit"; - -import { getReadTheDocsConfig } from "./readthedocs-config"; -import * as notification from "./notification"; -import * as analytics from "./analytics"; -import * as search from "./search"; -import * as docdiff from "./docdiff"; -import * as flyout from "./flyout"; -import * as ethicalads from "./ethicalads"; -import * as hotkeys from "./hotkeys"; -import * as linkpreviews from "./linkpreviews"; -import * as filetreediff from "./filetreediff"; -import * as customscript from "./customscript"; -import { default as objectPath } from "object-path"; -import { - docTool, - domReady, - isEmbedded, - IS_PRODUCTION, - setupLogging, - getMetadataValue, - setupHistoryEvents, -} from "./utils"; - -import doctoolsStyleSheet from "./doctools.css"; - -export function setup() { - const addons = [ - flyout.FlyoutAddon, - notification.NotificationAddon, - analytics.AnalyticsAddon, - ethicalads.EthicalAdsAddon, - search.SearchAddon, - - // HotKeys & FileTreeDiff have to be initialized before DocDiff because when - // `?readthedocs-diff=true` DocDiff triggers an event that HotKeys has to - // listen to to update its internal state. - // https://github.com/readthedocs/addons/blob/47645b013724cdf244716b549a5baa28409fafcb/src/docdiff.js#L105-L111 - hotkeys.HotKeysAddon, - filetreediff.FileTreeDiffAddon, - - linkpreviews.LinkPreviewsAddon, - customscript.CustomScriptAddon, - docdiff.DocDiffAddon, - ]; - - return new Promise((resolve) => { - domReady - .then(() => { - setupLogging(); - setupHistoryEvents(); - - let sendUrlParam = false; - for (const addon of addons) { - if (addon.requiresUrlParam()) { - sendUrlParam = true; - break; - } - } - - // Apply fixes to variables for individual documentation tools - const elementHtml = document.querySelector("html"); - if (elementHtml) { - // Inject styles at the parent DOM to set variables at :root - let styleSheet = doctoolsStyleSheet; - if (doctoolsStyleSheet instanceof CSSResult) { - styleSheet = doctoolsStyleSheet.styleSheet; - } - document.adoptedStyleSheets = [styleSheet]; - - // If we detect a documentation tool, set attributes on :root to allow - // for CSS selectors to utilize these values. - if (docTool.documentationTool) { - elementHtml.setAttribute( - "data-readthedocs-tool", - docTool.documentationTool, - ); - } - if (docTool.documentationTheme) { - elementHtml.setAttribute( - "data-readthedocs-tool-theme", - docTool.documentationTheme, - ); - } - } - - return getReadTheDocsConfig(sendUrlParam); - }) - .then((config) => { - const loadWhenEmbedded = objectPath.get( - config, - "addons.options.load_when_embedded", - false, - ); - if (isEmbedded() && !loadWhenEmbedded) { - return false; - } - - const httpStatus = getMetadataValue("readthedocs-http-status"); - let promises = []; - - if (!IS_PRODUCTION) { - // Addons that are only available on development - console.log("Development mode."); - } - - for (const addon of addons) { - if (addon.isEnabled(config, httpStatus)) { - promises.push( - new Promise((resolve) => { - return resolve(new addon(config)); - }), - ); - } - } - return Promise.all(promises); - }) - .then(() => { - return resolve(); - }) - .catch((err) => { - console.error(err); - }); - }); -} diff --git a/src/init.js b/src/init.js index 2a5f454f..39dead57 100644 --- a/src/init.js +++ b/src/init.js @@ -1,3 +1 @@ -import * as readthedocs from "./index"; - -readthedocs.setup(); +import * as application from "./application"; diff --git a/src/linkpreviews.js b/src/linkpreviews.js index c1aec489..8ca38d27 100644 --- a/src/linkpreviews.js +++ b/src/linkpreviews.js @@ -272,7 +272,9 @@ export class LinkPreviewsElement extends LitElement { _handleRootDOMChanged = (e) => { // Trigger the setup again since the DOM has changed - this.setupTooltips(); + if (this.config) { + this.setupTooltips(); + } }; connectedCallback() { @@ -302,22 +304,7 @@ export class LinkPreviewsAddon extends AddonBase { "http://v1.schemas.readthedocs.org/addons.linkpreviews.json"; static addonEnabledPath = "addons.linkpreviews.enabled"; static addonName = "LinkPreviews"; - - constructor(config) { - super(); - - // If there are no elements found, inject one - let elems = document.querySelectorAll("readthedocs-linkpreviews"); - if (!elems.length) { - elems = [new LinkPreviewsElement()]; - document.body.append(elems[0]); - elems[0].requestUpdate(); - } - - for (const elem of elems) { - elem.loadConfig(config); - } - } + static elementClass = LinkPreviewsElement; } -customElements.define("readthedocs-linkpreviews", LinkPreviewsElement); +customElements.define(LinkPreviewsElement.elementName, LinkPreviewsElement); diff --git a/src/notification.js b/src/notification.js index 3bf491ea..aca8f11e 100644 --- a/src/notification.js +++ b/src/notification.js @@ -280,7 +280,10 @@ export class NotificationElement extends LitElement { if (stableVersion !== undefined) { this.stableVersionAvailable = true; - this.urls.stable = getLinkWithFilename(stableVersion.urls.documentation); + this.urls.stable = getLinkWithFilename( + stableVersion.urls.documentation, + this.config.readthedocs.resolver.filename, + ); } } @@ -410,22 +413,7 @@ export class NotificationAddon extends AddonBase { "http://v1.schemas.readthedocs.org/addons.notifications.json"; static addonEnabledPath = "addons.notifications.enabled"; static addonName = "Notification"; - - constructor(config) { - super(); - - // If there are no elements found, inject one - let elems = document.querySelectorAll("readthedocs-notification"); - if (!elems.length) { - elems = [new NotificationElement()]; - document.body.append(elems[0]); - elems[0].requestUpdate(); - } - - for (const elem of elems) { - elem.loadConfig(config); - } - } + static elementClass = NotificationElement; } -customElements.define("readthedocs-notification", NotificationElement); +customElements.define(NotificationElement.elementName, NotificationElement); diff --git a/src/readthedocs-config.js b/src/readthedocs-config.js index aebdfa94..df7b6d8f 100644 --- a/src/readthedocs-config.js +++ b/src/readthedocs-config.js @@ -1,6 +1,7 @@ import { default as fetch } from "unfetch"; import { - EVENT_READTHEDOCS_ADDONS_DATA_READY, + EVENT_READTHEDOCS_ADDONS_USER_DATA_READY, + EVENT_READTHEDOCS_ADDONS_INTERNAL_DATA_READY, ReadTheDocsEventData, } from "./events"; import { @@ -108,6 +109,12 @@ export function getReadTheDocsConfig(sendUrlParam) { return response.json(); }) .then((data) => { + return dispatchEvent( + EVENT_READTHEDOCS_ADDONS_INTERNAL_DATA_READY, + document, + new ReadTheDocsEventData(data), + ); + // Trigger a new task here to hit the API again in case the version // request missmatchs the one the user expects. getReadTheDocsUserConfig(sendUrlParam).then((dataUser) => { @@ -120,7 +127,7 @@ export function getReadTheDocsConfig(sendUrlParam) { // Trigger the addons data ready CustomEvent to with the data the user is expecting. return dispatchEvent( - EVENT_READTHEDOCS_ADDONS_DATA_READY, + EVENT_READTHEDOCS_ADDONS_USER_DATA_READY, document, new ReadTheDocsEventData(dataEvent), ); diff --git a/src/search.js b/src/search.js index 189e9a11..ec83c2dd 100644 --- a/src/search.js +++ b/src/search.js @@ -700,25 +700,7 @@ export class SearchAddon extends AddonBase { static addonEnabledPath = "addons.search.enabled"; static addonName = "Search"; static enabledOnHttpStatus = [200, 404]; - - constructor(config) { - super(); - - // If there are no elements found, inject one - let elems = document.querySelectorAll("readthedocs-search"); - if (!elems.length) { - elems = [new SearchElement()]; - - // We cannot use `render(elems[0], document.body)` because there is a race conditions between all the addons. - // So, we append the web-component first and then request an update of it. - document.body.append(elems[0]); - elems[0].requestUpdate(); - } - - for (const elem of elems) { - elem.loadConfig(config); - } - } + static elementClass = SearchElement; } -customElements.define("readthedocs-search", SearchElement); +customElements.define(SearchElement.elementName, SearchElement); diff --git a/src/utils.js b/src/utils.js index 0f0291b4..7c6e2645 100644 --- a/src/utils.js +++ b/src/utils.js @@ -82,6 +82,34 @@ export class AddonBase { static addonLocalStorageKey = null; static enabledOnHttpStatus = [200]; + constructor(config) { + // Store all the Read the Docs web component elements + this.elements = []; + + // If the addon class defines a web component element, we query/instanciate it before initializing it. + if (this.constructor.elementClass !== undefined) { + // If there are no elements found, inject one + this.elements = document.querySelectorAll( + this.constructor.elementClass.elementName, + ); + if (!this.elements.length) { + this.elements = [new this.constructor.elementClass()]; + + // We cannot use `render(this.elements[0], document.body)` because there is a race conditions between all the addons. + // So, we append the web-component first and then request an update of it. + document.body.append(this.elements[0]); + } + } + + this.loadConfig(config); + } + + loadConfig(config) { + for (const element of this.elements) { + element.loadConfig(config); + } + } + /** * Validates the given configuration object against a predefined JSON schema. * @@ -177,7 +205,7 @@ export class AddonBase { */ export function setupHistoryEvents() { // Let's ensure that the history will be patched only once, so we create a Symbol to check by - const patchKey = Symbol.for("addons_history"); + const patchKey = Symbol.for("addons-history"); if ( typeof history !== "undefined" && @@ -186,11 +214,34 @@ export function setupHistoryEvents() { for (const methodName of ["pushState", "replaceState"]) { const originalMethod = history[methodName]; history[methodName] = function () { + // Save the from URL to compare against before triggering the event. + const fromURL = new URL(window.location.href); + const result = originalMethod.apply(this, arguments); - const event = new Event(EVENT_READTHEDOCS_URL_CHANGED); - event.arguments = arguments; - dispatchEvent(event); + // Dispatch the event only when the third argument (url) is passed. + // Otherwise, we are triggering the event even then the URL hasn't changed. + // + // https://developer.mozilla.org/en-US/docs/Web/API/History/pushState + // https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState + if (arguments.length === 3) { + const toURL = arguments[2]; + + // TODO: we can't import this here -- it has to be at the top. + // We can't import it at the top due to circular dependencies. + // I'm using the hardcoded name for now. + // + // import { DOCDIFF_URL_PARAM } from "./docdiff"; + toURL.searchParams.delete("readthedocs-diff"); + + // Dispatch the event only if the new URL is not just the DOCDIFF_URL_PARAM added. + if (toURL.href !== fromURL.href) { + const event = new Event(EVENT_READTHEDOCS_URL_CHANGED); + event.arguments = arguments; + dispatchEvent(event); + } + } + return result; }; } @@ -298,17 +349,29 @@ export function getMetadataValue(name) { * Resulting URL: https://docs.readthedocs.io/en/latest/ * */ -export function getLinkWithFilename(url) { - // Get the resolver's filename returned by the application (as HTTP header) - // and injected by Cloudflare Worker as a meta HTML tag - const metaFilename = getMetadataValue("readthedocs-resolver-filename"); +export function getLinkWithFilename(url, resolverFilename) { + if (!resolverFilename) { + if (docTool.isSinglePageApplication()) { + // SPA without ``resolverFilename``. + // Just a protection, this shouldn't happen. + return new URL(url); + } else { + // No SPA without ``resolverFilename``. + // Normal case for most of the documentation tools. + // Get the resolver's filename returned by the application (as HTTP header) + // and injected by Cloudflare Worker as a meta HTML tag + const resolverFilename = getMetadataValue( + "readthedocs-resolver-filename", + ); + } + } // Keep only one trailing slash const base = url.replace(/\/+$/, "/"); // 1. remove initial slash to make it relative to the base // 2. remove the trailing "index.html" - const filename = metaFilename + const filename = resolverFilename .replace(/\/index.html$/, "/") .replace(/^\//, ""); @@ -339,6 +402,8 @@ export class DocumentationTool { [FALLBACK_DOCTOOL]: ["p a"], }; + static SINGLE_PAGE_APPLICATIONS = [VITEPRESS, MDBOOK, DOCUSAURUS, DOCSIFY]; + constructor() { this.documentationTool = this.getDocumentationTool(); this.documentationTheme = this.getDocumentationTheme(); @@ -492,6 +557,14 @@ export class DocumentationTool { return null; } + isSinglePageApplication() { + const isSPA = DocumentationTool.SINGLE_PAGE_APPLICATIONS.includes( + this.documentationTool, + ); + console.debug("isSinglePageApplication:", isSPA); + return isSPA; + } + isAntora() { if ( document.querySelectorAll('meta[name="generator"][content^="Antora"]') diff --git a/tests/filetreediff.test.js b/tests/filetreediff.test.js index d9447b77..bf170ab8 100644 --- a/tests/filetreediff.test.js +++ b/tests/filetreediff.test.js @@ -28,6 +28,11 @@ describe("FileTreeDiff addon", () => { }, }, }, + versions: { + current: { + type: "external", + }, + }, }), ).to.be.false; }); @@ -45,6 +50,11 @@ describe("FileTreeDiff addon", () => { }, }, }, + versions: { + current: { + type: "external", + }, + }, }), ).to.be.true; }); diff --git a/tests/flyout.test.html b/tests/flyout.test.html index d9fe19b5..a80ccd21 100644 --- a/tests/flyout.test.html +++ b/tests/flyout.test.html @@ -22,6 +22,11 @@ enabled: true, }, }, + readthedocs: { + resolver: { + filename: "/", + }, + }, projects: { current: { slug: "project", diff --git a/tests/flyout.test.js b/tests/flyout.test.js index 329f270f..65f2b52a 100644 --- a/tests/flyout.test.js +++ b/tests/flyout.test.js @@ -25,6 +25,11 @@ describe("Flyout addon", () => { versions: [], }, }, + readthedocs: { + resolver: { + filename: "/index.html", + }, + }, projects: { current: { slug: "project", @@ -49,6 +54,11 @@ describe("Flyout addon", () => { enabled: true, }, }, + readthedocs: { + resolver: { + filename: "/index.html", + }, + }, projects: { current: { slug: "project", @@ -92,6 +102,11 @@ describe("Flyout addon", () => { enabled: true, }, }, + readthedocs: { + resolver: { + filename: "/index.html", + }, + }, projects: { current: { slug: "project", @@ -135,6 +150,11 @@ describe("Flyout addon", () => { enabled: true, }, }, + readthedocs: { + resolver: { + filename: "/index.html", + }, + }, projects: { current: { slug: "project", diff --git a/tests/index.test.html b/tests/index.test.html deleted file mode 100644 index 63dd247a..00000000 --- a/tests/index.test.html +++ /dev/null @@ -1,130 +0,0 @@ - - - - - - - diff --git a/tests/index.test.js b/tests/index.test.js deleted file mode 100644 index ab200577..00000000 --- a/tests/index.test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { expect } from "@open-wc/testing"; -import * as readthedocs from "../src/index.js"; - -describe("Main library", () => { - it("is defined", () => { - expect(readthedocs).to.exist; - }); - it("setup() function is defined", () => { - expect(readthedocs.setup).to.exist; - }); -}); diff --git a/tests/notification.test.html b/tests/notification.test.html index 28e0c122..a841a4da 100644 --- a/tests/notification.test.html +++ b/tests/notification.test.html @@ -39,6 +39,11 @@ show_on_external: true, }, }, + readthedocs: { + resolver: { + filename: "/section/page.html", + }, + }, builds: { current: { urls: {