diff --git a/backend/models/pages.ts b/backend/models/pages.ts
index 15453098e..38ff8d36e 100644
--- a/backend/models/pages.ts
+++ b/backend/models/pages.ts
@@ -350,8 +350,10 @@ class Pages {
* Flatten a row and its blobs into the shape the API returns.
*
* @param locked Withhold the body — the source, the rendered HTML, the table of contents drawn from
- * it, and the relation links written onto the page. The metadata stays: a reader
- * looking at the lock screen is told what page they are being asked for a password to.
+ * it, the relation links written onto the page, and the page's own CSS and scripts,
+ * which the page view runs against content this reader has not been given. The
+ * metadata stays: a reader looking at the lock screen is told what page they are
+ * being asked for a password to.
* @param withPassword Include the page's own password. Only for a requester who may edit the page,
* which is the one that has to be able to read it back and save it again.
* @param withContent Include the source. A redirection's comes back either way: its content is not
@@ -405,9 +407,9 @@ class Pages {
showTags: config.showTags ?? true,
showToc: config.showToc ?? true,
tocDepth: config.tocDepth ?? { min: 1, max: 2 },
- scriptJsLoad: scripts.jsLoad ?? '',
- scriptJsUnload: scripts.jsUnload ?? '',
- scriptCss: scripts.css ?? '',
+ scriptJsLoad: locked ? '' : (scripts.jsLoad ?? ''),
+ scriptJsUnload: locked ? '' : (scripts.jsUnload ?? ''),
+ scriptCss: locked ? '' : (scripts.css ?? ''),
navigationId: row.navigationId ?? null,
navigationMode: row.navigationMode ?? 'inherit',
authorId: row.authorId,
diff --git a/frontend/src/components/PagePropertiesDialog.vue b/frontend/src/components/PagePropertiesDialog.vue
index a47a16859..c8377d0c5 100644
--- a/frontend/src/components/PagePropertiesDialog.vue
+++ b/frontend/src/components/PagePropertiesDialog.vue
@@ -166,10 +166,16 @@
{{ t('editor.props.localeRelationsHint') }}
-
+
+
{{ t('editor.props.jsUnloadHint') }}
+
userStore.pagePermissions.includes('write:scripts'))
+const mayWriteStyles = computed(() => userStore.pagePermissions.includes('write:styles'))
+
+/*
+ The rail of jump links down the side of the panel. A computed rather than a constant because the
+ Scripts section is not always there, and a link to a section that is not rendered is a link that
+ throws -- `jumpToSection` reads the element straight off the document.
+*/
+const quickaccess = computed(() =>
+ [
+ { key: 'refCardInfo', icon: 'la:info-circle', label: t('editor.props.info') },
+ { key: 'refCardPublishState', icon: 'la:power-off', label: t('editor.props.publishState') },
+ { key: 'refCardRelations', icon: 'la:sun', label: t('editor.props.relations') },
+ {
+ key: 'refCardScripts',
+ icon: 'la:code',
+ label: t('editor.props.scripts'),
+ shown: mayWriteScripts.value || mayWriteStyles.value
+ },
+ { key: 'refCardSidebar', icon: 'la:ruler-vertical', label: t('editor.props.sidebar') },
+ { key: 'refCardSocial', icon: 'la:comments', label: t('editor.props.social') },
+ { key: 'refCardTags', icon: 'la:tags', label: t('editor.props.tags') },
+ { key: 'refCardVisibility', icon: 'la:eye', label: t('editor.props.visibility') }
+ ].filter((qa) => qa.shown !== false)
+)
+
const publishingRange = computed({
get() {
return {
diff --git a/frontend/src/components/PageScriptsDialog.vue b/frontend/src/components/PageScriptsDialog.vue
index 60d5685c9..1d410c18f 100644
--- a/frontend/src/components/PageScriptsDialog.vue
+++ b/frontend/src/components/PageScriptsDialog.vue
@@ -52,9 +52,10 @@ import UtilCodeEditor from './UtilCodeEditor.vue'
// PROPS
const props = defineProps({
+ /** Which of the three the dialog is editing: `jsLoad`, `jsUnload` or `styles`. */
mode: {
type: String,
- default: 'css'
+ default: 'styles'
}
})
@@ -102,15 +103,28 @@ const languageLabel = computed(() => {
}
})
-const contentStoreKey = computed(() => {
- return 'script' + props.mode.charAt(0).toUpperCase() + props.mode.slice(1)
-})
+/*
+ Which store field this dialog is editing. A table rather than a name built from `mode`, because the
+ two do not line up: the CSS mode is called `styles` -- it is the button in the properties panel and
+ the translation key of its label -- while the field it writes is `scriptCss`. Assembled, it spelled
+ `scriptStyles`, a field the store does not have, so the CSS editor opened empty on a page that had
+ CSS and saved into nothing.
+*/
+const STORE_KEYS = {
+ jsLoad: 'scriptJsLoad',
+ jsUnload: 'scriptJsUnload',
+ styles: 'scriptCss'
+}
+
+const contentStoreKey = computed(() => STORE_KEYS[props.mode])
// METHODS
function persist() {
+ // -> `.value`: the computed itself as a key stringifies to `[object Object]`, which is where every
+ // edit made in this dialog used to go
pageStore.$patch({
- [contentStoreKey]: state.content
+ [contentStoreKey.value]: state.content
})
}
@@ -129,7 +143,7 @@ function saveAndClose() {
// -> No deferred mount: the quarter-second wait was there to give the old editor a laid-out container
// to measure itself against, and a textarea needs no such thing
onMounted(() => {
- state.content = pageStore[contentStoreKey.value]
+ state.content = pageStore[contentStoreKey.value] ?? ''
// -> The editor is what this dialog is for, so the caret starts there. After the tick that renders
// the content above, so focus lands on a field that is already populated.
nextTick(() => {
diff --git a/frontend/src/components/shared/WToggle.vue b/frontend/src/components/shared/WToggle.vue
index 55023ca94..071aa79ca 100644
--- a/frontend/src/components/shared/WToggle.vue
+++ b/frontend/src/components/shared/WToggle.vue
@@ -21,8 +21,16 @@
:class="[dense ? 'size-4' : 'size-5', knobOffset]" />
+
+
` element rather than `new Function(code)()`, for three reasons. It runs the code as a
+ * classic script at top level, so `var`, a function declaration or a `const` the author expects to
+ * find from an inline handler in the content lands where they expect it. It throws nowhere this
+ * caller can see -- a broken page script is reported as an uncaught error on `window`, the way a
+ * broken script in a document is, instead of taking the page view's own code down with it. And under
+ * a Content-Security-Policy it needs `script-src 'unsafe-inline'` rather than `'unsafe-eval'`, which
+ * is the lesser of the two to have to allow.
+ *
+ * The element is removed the moment it has run: insertion is what executes it and it is inert from
+ * then on, so leaving it behind would only pile up one dead element per navigation.
+ *
+ * @param {string} code
+ */
+function execute(code) {
+ if (!code?.trim()) {
+ return
+ }
+ const el = document.createElement('script')
+ el.type = 'text/javascript'
+ el.textContent = code
+ document.body.appendChild(el)
+ el.remove()
+}
+
+/**
+ * The page's own CSS and Javascript, on the page the reader is looking at.
+ *
+ * The three fields the properties panel writes -- `scriptCss`, `scriptJsLoad`, `scriptJsUnload` --
+ * are stored per page and, until this existed, were never applied to anything: they came down with
+ * every page and nothing read them. This is where they take effect, in the page view, because that is
+ * where the page's content actually is.
+ *
+ * What runs when:
+ *
+ * - **CSS** is in force for as long as the page is on screen, the editor included -- an author
+ * writing rules for a page wants to see them applied to it, and the dialog patches the store as it
+ * is saved, so the change shows immediately.
+ * - **`jsLoad`** runs once the page's content is in the document, which is the tick after the store
+ * changes: the element the script goes looking for has to exist by then.
+ * - **`jsUnload`** runs before that content is destroyed -- walking to another page, opening the
+ * editor over it, or leaving the page view altogether. The code that runs is the one the page on
+ * screen came with, captured when its load script ran, since by the time a navigation gets here the
+ * store already holds the page arriving.
+ *
+ * What does NOT run either of them is the script itself changing. The properties panel writes these
+ * fields into the page store as they are edited -- the panel opens over a page being read as well as
+ * over one being written -- so a watcher on the code would run an author's Javascript the moment they
+ * pressed Save in the editor dialog, against the page behind it and before it was saved anywhere.
+ * Arriving at the page is the event; the code is read when it happens.
+ *
+ * A page nobody is reading runs nothing: not while the editor is over it -- what is on screen there is
+ * a preview being typed into rather than the page -- not on a path with no page, and not on a locked
+ * one, whose body the server never sent.
+ *
+ * A full document unload (a reload, a closed tab) is deliberately not one of the cases: the document
+ * and everything the script did to it are going away regardless, and a `beforeunload` handler that
+ * runs a page's arbitrary Javascript is a good way to make leaving the wiki slow.
+ */
+export function usePageScripts() {
+ const editorStore = useEditorStore()
+ const pageStore = usePageStore()
+ const route = useRoute()
+
+ /** The unload script of the page currently on screen, kept from when its load script ran. */
+ let liveUnload = ''
+
+ /** Whether there is a page on screen at all -- one that arrived, with a body to style or script. */
+ const hasPage = computed(
+ () => Boolean(pageStore.id) && !pageStore.notFound && !pageStore.isLocked
+ )
+
+ watch(() => (hasPage.value ? pageStore.scriptCss : ''), applyStyles, { immediate: true })
+
+ /**
+ * Whether the page on screen is being written rather than read.
+ *
+ * Two answers, because neither covers the other. `isActive` is the editor being opened over a page
+ * from the page's own URL, which is how the Edit button does it -- the route never moves. The route
+ * test is for arriving at `/_edit/` or `/_create` directly, where the editor is what the
+ * reader asked for but is not open yet: the page is fetched into the store first and the editor's
+ * own configuration after that, and in the gap between the two nothing but the URL says that this
+ * page is not simply being read. That gap is a round trip wide, so it is not one a later re-check
+ * can close -- the script would have run long before.
+ */
+ const isEditing = computed(
+ () => editorStore.isActive || /^\/_(edit|create)(\/|$)/.test(route.path)
+ )
+
+ /**
+ * The page whose Javascript is live: its id while it is being read, and null while it is not -- no
+ * page, a locked one, or the editor over it.
+ */
+ const liveId = computed(() => (hasPage.value && !isEditing.value ? pageStore.id : null))
+
+ /*
+ So the callback fires on arriving at a page, on leaving one, and on the editor opening and closing
+ over one, and on nothing else.
+
+ A `pre` watcher (the default), which is the whole reason the unload script still has a page to act
+ on: it runs before the components re-render, so the store already describes the page arriving
+ while the document still holds the one being left. (The URL has moved on by then too -- the router
+ navigates first -- so a script reading `location` during unload sees where the reader is going.)
+ */
+ watch(
+ liveId,
+ (next) => {
+ runUnload()
+ if (!next) {
+ return
+ }
+ // -> The tick that puts the new page's content in the document; the script runs against it
+ nextTick(() => {
+ /*
+ Asked again rather than trusted from the watcher: a tick is long enough for the reader to
+ have gone somewhere else, and this script is only ever meant to run against the page that is
+ actually on screen when it does.
+ */
+ if (liveId.value !== next) {
+ return
+ }
+ liveUnload = pageStore.scriptJsUnload
+ execute(pageStore.scriptJsLoad)
+ })
+ },
+ { immediate: true }
+ )
+
+ /** Run the on-screen page's unload script, once. */
+ function runUnload() {
+ const code = liveUnload
+ liveUnload = ''
+ execute(code)
+ }
+
+ // -> The page view itself going away: the search screen, the admin area, a profile page
+ onScopeDispose(() => {
+ runUnload()
+ applyStyles('')
+ })
+}
diff --git a/frontend/src/pages/Index.vue b/frontend/src/pages/Index.vue
index f35b78191..1bc122ec1 100644
--- a/frontend/src/pages/Index.vue
+++ b/frontend/src/pages/Index.vue
@@ -354,6 +354,7 @@ import { useDark } from '@/composables/dark'
import { dialog } from '@/composables/dialog'
import { useMeta } from '@/composables/meta'
import { useMinWidth } from '@/composables/screen'
+import { usePageScripts } from '@/composables/pageScripts'
import { notify } from '@/composables/notify'
import { withViewTransition } from '@/composables/viewTransition'
import { loading } from '@/composables/loading'
@@ -421,6 +422,12 @@ const { t } = useI18n()
const dark = useDark()
+/*
+ The page's own CSS and Javascript, from the properties panel. Nothing to hold onto -- it follows the
+ page store on its own, for as long as this view is mounted.
+*/
+usePageScripts()
+
// META
/*