diff --git a/README.md b/README.md
index 7550e90d..c8c4e946 100644
--- a/README.md
+++ b/README.md
@@ -476,6 +476,8 @@ And then add this node in the place where you want to see Remark42 widget:
After that widget will be rendered inside this node.
+If you want to set this up on a Single Page App, see [/docs/spa.md](/docs/spa.md).
+
##### Themes
Right now Remark has two themes: light and dark.
diff --git a/docs/README.md b/docs/README.md
index d54c0310..99f8059c 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -2,6 +2,7 @@
- [How to configure remark42 with nginx reverse proxy](nginx-proxy.md)
- [How to configure remark42 without a subdomain](subdomain.md) with Nginx or Caddy
+- [How to configure remark42 for Single Page Apps (SPA)](spa.md)
- [Telegram notifications](telegram.md)
- [Setup email authentication and\or email notifications](email.md)
- [How to add new translation to remark42](translation.md)
diff --git a/docs/spa.md b/docs/spa.md
new file mode 100644
index 00000000..d2fc8e4a
--- /dev/null
+++ b/docs/spa.md
@@ -0,0 +1,68 @@
+## How to configure remark42 for Single Page Apps (SPA)
+
+Originally tested on [Nuxt.js](https://nuxtjs.org/), but it should be applicable to all SPAs.
+
+- Add the following JavaScript to your `index.html`, which in this case, it is identical to ``
+
+```js
+;(function () {
+ var host = // Your remark42 host
+ var components = ['embed'] // Your choice of remark42 components
+
+ ;(function(c) {
+ for (let i = 0; i < c.length; i++) {
+ const d = document
+ const s = d.createElement('script')
+ s.src = remark_config.host + '/web/' + c[i] + '.js'
+ s.defer = true
+ ;(d.head || d.body).appendChild(s)
+ }
+ })(components)
+})
+```
+
+- Created `remark42Instance` when the `div` containing remark42 has appear, usually at `mounted` or `componentDidMount` of the SPA lifecycle. Destroy the previous instance first, if neccessary.
+
+```ts
+ initRemark42() {
+ if (window.REMARK42) {
+ if (this.remark42Instance) {
+ this.remark42Instance.destroy()
+ }
+
+ this.remark42Instance = window.REMARK42.createInstance({
+ node: this.$refs.remark42 as HTMLElement,
+ ...remark42_config // See
+ })
+ }
+ }
+
+ mounted() {
+ if (window.REMARK42) {
+ this.initRemark42()
+ } else {
+ window.addEventListener('REMARK42::ready', () => {
+ this.initRemark42()
+ })
+ }
+ }
+```
+
+- Ensure that this is called every time route changes
+
+```ts
+ @Watch('$route.path')
+ onRouteChange() {
+ this.initRemark42()
+ }
+```
+
+- And, destroyed before routeLeave
+
+```ts
+ beforeRouteLeave() {
+ if (this.remark42Instance) {
+ this.remark42Instance.destroy()
+ }
+ }
+```
diff --git a/frontend/.size-limit.js b/frontend/.size-limit.js
index 1379630b..b9339f9e 100644
--- a/frontend/.size-limit.js
+++ b/frontend/.size-limit.js
@@ -1,7 +1,7 @@
module.exports = [
{
path: 'public/embed.js',
- limit: '2.55 KB',
+ limit: '2.7 KB',
},
{
limit: '86 KB',
diff --git a/frontend/app/@types/global.d.ts b/frontend/app/@types/global.d.ts
index 77495f01..0e417ea5 100644
--- a/frontend/app/@types/global.d.ts
+++ b/frontend/app/@types/global.d.ts
@@ -5,7 +5,16 @@ declare global {
interface Window {
remark_config: CommentsConfig;
REMARK42: {
- changeTheme(theme: Theme): void;
+ changeTheme?: (theme: Theme) => void;
+ destroy?: () => void;
+ createInstance: (
+ remark_config: CommentsConfig
+ ) =>
+ | {
+ changeTheme(theme: Theme): void;
+ destroy(): void;
+ }
+ | undefined;
};
}
diff --git a/frontend/app/common/config-types.ts b/frontend/app/common/config-types.ts
index 37f83280..5a48c825 100644
--- a/frontend/app/common/config-types.ts
+++ b/frontend/app/common/config-types.ts
@@ -15,7 +15,7 @@ export interface CommentsConfig {
max_shown_comments?: number;
theme?: Theme;
page_title?: string;
- node?: string;
+ node?: string | HTMLElement;
locale?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
__colors__?: any;
diff --git a/frontend/app/embed.ts b/frontend/app/embed.ts
index 38520290..b6a5e0b0 100644
--- a/frontend/app/embed.ts
+++ b/frontend/app/embed.ts
@@ -1,6 +1,7 @@
/* eslint-disable no-console */
import { BASE_URL, NODE_ID, COMMENT_NODE_CLASSNAME_PREFIX } from '@app/common/constants.config';
import { UserInfo, Theme } from '@app/common/types';
+import { CommentsConfig } from './common/config-types';
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
@@ -47,41 +48,50 @@ function createFrame({
}
function init() {
- const node = document.getElementById(window.remark_config.node || NODE_ID);
+ window.REMARK42 = window.REMARK42 || {};
+ window.REMARK42.createInstance = createInstance;
+
+ if (window.remark_config) {
+ createInstance(window.remark_config);
+ }
+
+ window.dispatchEvent(new Event('REMARK42::ready'));
+}
+
+function createInstance(config: CommentsConfig) {
+ let initDataAnimationTimeout: number | null = null;
+ let titleObserver: MutationObserver | null = null;
+
+ try {
+ config = config || {};
+ } catch (e) {
+ console.error('Remark42: Config object is undefined.');
+ return;
+ }
+
+ if (!config.site_id) {
+ console.error('Remark42: Site ID is undefined.');
+ return;
+ }
+
+ config.url = (config.url || window.location.origin + window.location.pathname).split('#')[0];
+
+ const node = config.node instanceof HTMLElement ? config.node : document.getElementById(config.node || NODE_ID);
if (!node) {
console.error("Remark42: Can't find root node.");
return;
}
- try {
- window.remark_config = window.remark_config || {};
- } catch (e) {
- console.error('Remark42: Config object is undefined.');
- return;
- }
-
- if (!window.remark_config.site_id) {
- console.error('Remark42: Site ID is undefined.');
- return;
- }
-
- window.remark_config.url = (window.remark_config.url || window.location.origin + window.location.pathname).split(
- '#'
- )[0];
-
- window.REMARK42 = window.REMARK42 || {};
- window.REMARK42.changeTheme = changeTheme;
-
- const query = Object.keys(window.remark_config)
+ const query = Object.keys(config)
.filter(key => key !== '__colors__')
.map(key => {
- return `${encodeURIComponent(key)}=${encodeURIComponent(
- window.remark_config[key as keyof typeof window.remark_config]
- )}`;
+ return `${encodeURIComponent(key)}=${encodeURIComponent(config[key as keyof typeof config])}`;
})
.join('&');
- const iframe = createFrame({ host: BASE_URL, query, __colors__: window.remark_config.__colors__ });
+ const iframe =
+ (document.querySelector('iframe[title=Remark42]') as HTMLIFrameElement) ||
+ createFrame({ host: BASE_URL, query, __colors__: config.__colors__ });
node.appendChild(iframe);
@@ -91,7 +101,8 @@ function init() {
const titleElement = document.querySelector('title');
if (titleElement) {
- new MutationObserver(mutations => postTitleToIframe(mutations[0].target.textContent!)).observe(titleElement, {
+ titleObserver = new MutationObserver(mutations => postTitleToIframe(mutations[0].target.textContent!));
+ titleObserver.observe(titleElement, {
subtree: true,
characterData: true,
childList: true,
@@ -126,54 +137,54 @@ function init() {
this.style.setAttribute('rel', 'stylesheet');
this.style.setAttribute('type', 'text/css');
this.style.innerHTML = `
- #${remarkRootId}-node {
- position: fixed;
- top: 0;
- right: 0;
- bottom: 0;
- width: 400px;
- transition: transform 0.4s ease-out;
- max-width: 100%;
- transform: translate(400px, 0);
- }
- #${remarkRootId}-node[data-animation] {
- transform: translate(0, 0);
- }
- #${remarkRootId}-back {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0,0,0,0.7);
- opacity: 0;
- transition: opacity 0.4s ease-out;
- }
- #${remarkRootId}-back[data-animation] {
- opacity: 1;
- }
+ #${remarkRootId}-node {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 400px;
+ transition: transform 0.4s ease-out;
+ max-width: 100%;
+ transform: translate(400px, 0);
+ }
+ #${remarkRootId}-node[data-animation] {
+ transform: translate(0, 0);
+ }
+ #${remarkRootId}-back {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0,0,0,0.7);
+ opacity: 0;
+ transition: opacity 0.4s ease-out;
+ }
+ #${remarkRootId}-back[data-animation] {
+ opacity: 1;
+ }
+ #${remarkRootId}-close {
+ top: 0px;
+ right: 400px;
+ position: absolute;
+ text-align: center;
+ font-size: 25px;
+ cursor: pointer;
+ color: white;
+ border-color: transparent;
+ border-width: 0;
+ padding: 0;
+ margin-right: 4px;
+ background-color: transparent;
+ }
+ @media all and (max-width: 430px) {
#${remarkRootId}-close {
- top: 0px;
- right: 400px;
- position: absolute;
- text-align: center;
- font-size: 25px;
- cursor: pointer;
- color: white;
- border-color: transparent;
- border-width: 0;
- padding: 0;
- margin-right: 4px;
- background-color: transparent;
+ right: 0px;
+ font-size: 20px;
+ color: black;
}
- @media all and (max-width: 430px) {
- #${remarkRootId}-close {
- right: 0px;
- font-size: 20px;
- color: black;
- }
- }
- `;
+ }
+ `;
}
if (!this.node) {
this.node = document.createElement('div');
@@ -202,10 +213,10 @@ function init() {
document.body.appendChild(this.back);
document.body.appendChild(this.node);
document.addEventListener('keydown', this.onKeyDown);
- setTimeout(() => {
+ initDataAnimationTimeout = window.setTimeout(() => {
this.back!.setAttribute('data-animation', '');
this.node!.setAttribute('data-animation', '');
- this.iframe!.focus();
+ iframe.focus();
}, 400);
},
close() {
@@ -306,4 +317,33 @@ function init() {
function changeTheme(theme: Theme) {
iframe.contentWindow!.postMessage(JSON.stringify({ theme }), '*');
}
+
+ function destroy() {
+ if (initDataAnimationTimeout) {
+ clearTimeout(initDataAnimationTimeout);
+ }
+
+ window.removeEventListener('message', receiveMessages);
+ window.removeEventListener('hashchange', postHashToIframe);
+ document.removeEventListener('click', postClickOutsideToIframe);
+
+ if (titleObserver) {
+ titleObserver.disconnect();
+ }
+
+ iframe.remove();
+ }
+
+ // TODO: These do not appear in Chrome DevTools
+ window.REMARK42.changeTheme = changeTheme;
+ window.REMARK42.destroy = () => {
+ destroy();
+ delete window.REMARK42.changeTheme;
+ delete window.REMARK42.destroy;
+ };
+
+ return {
+ changeTheme,
+ destroy,
+ };
}