diff --git a/frontend/src/css/_page-contents.scss b/frontend/src/css/_page-contents.scss
index d40530ef8..2abaae8ce 100644
--- a/frontend/src/css/_page-contents.scss
+++ b/frontend/src/css/_page-contents.scss
@@ -1260,14 +1260,18 @@
}
}
- /* -> Where a footnote link has just landed, so the reader can see which one they were sent to */
- .footnote-item:target,
- h1:target,
- h2:target,
- h3:target,
- h4:target,
- h5:target,
- h6:target {
+ /*
+ Where a footnote link has just landed, so the reader can see which one they were sent to: one item
+ among a list of near-identical ones, where arriving says nothing about which is which.
+
+ Keyed off the class `helpers/anchors.js` leaves on it rather than `:target`, which stopped matching
+ once an in-content fragment link began updating the URL with `router.push` -- a pushed hash sets no
+ target element. Keep the name in step with `LANDED_CLASS` there.
+
+ Headings are deliberately not styled: a heading is what the reader asked for and it announces itself
+ by sitting at the top of the column, so the wash only added noise to every jump to a section.
+ */
+ .footnote-item.is-anchor-landed {
/* Drawn outside the box so it cannot shift the text it highlights */
box-shadow: 0 0 0 0.4em var(--content-mark);
background-color: var(--content-mark);
diff --git a/frontend/src/helpers/anchors.js b/frontend/src/helpers/anchors.js
index 1c95966cf..652c92420 100644
--- a/frontend/src/helpers/anchors.js
+++ b/frontend/src/helpers/anchors.js
@@ -29,8 +29,27 @@ const SETTLE_MS = 1200
/** How far the heading may sit from where it was aimed before it is worth correcting, in pixels. */
const DRIFT_TOLERANCE = 4
+/**
+ * Left on whatever a fragment link landed on, for content styling to mark — a footnote does, since it
+ * is one item among a list of near-identical ones and being sent to it says nothing about which.
+ *
+ * `:target` used to do this and cannot any more: an in-content fragment link is followed with
+ * `router.push`, and a pushed hash does not set the document's target element. Doing it here instead
+ * covers arriving with a `#fragment` in the URL by the same path, which `:target` handled differently
+ * from a click. Styled in `_page-contents.scss` — the two have to be kept in step.
+ */
+export const LANDED_CLASS = 'is-anchor-landed'
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
+/** Mark where the reader has just been sent, and only there — the way `:target` behaved. */
+function markLanded(el) {
+ for (const previous of document.querySelectorAll(`.${LANDED_CLASS}`)) {
+ previous.classList.remove(LANDED_CLASS)
+ }
+ el.classList.add(LANDED_CLASS)
+}
+
/** The heading a `#slug` refers to, or null. */
export function anchorTarget(hash) {
const id = decodeURIComponent(String(hash ?? '').replace(/^#/, ''))
@@ -137,6 +156,7 @@ export function scrollToAnchor(hash, { smooth = false } = {}) {
if (!isVisible(target)) {
return false
}
+ markLanded(target)
scrollTo(target, smooth)
return true
}
@@ -171,6 +191,7 @@ export async function scrollToAnchorWhenReady(hash, { timeout = 5000 } = {}) {
if (!target || !isVisible(target)) {
return
}
+ markLanded(target)
const scroller = scrollerOf(target)
await whenStill(target, scroller, deadline)
diff --git a/frontend/src/helpers/renderedContent.js b/frontend/src/helpers/renderedContent.js
index ed3bb7271..c6b4688fa 100644
--- a/frontend/src/helpers/renderedContent.js
+++ b/frontend/src/helpers/renderedContent.js
@@ -184,8 +184,7 @@ export function enhanceRenderedContent(root) {
* - another origin, or a scheme that is not http(s) — `mailto:`, `tel:`, a download link
* - anything asking for a new context: `target`, `download`, `rel="external"`
* - a path the server owns rather than the router
- * - a bare fragment on the page already open, which the browser scrolls to and which fires the
- * `hashchange` the page view already listens for
+ * - a fragment on the page already open, which is `sameDocumentHash`'s business instead
*
* @param {object} link The anchor's own properties: `href` is the resolved absolute URL.
* @param {Location|{origin: string, pathname: string}} current Where the reader is now.
@@ -210,11 +209,43 @@ export function routableHref({ href, target, download, rel } = {}, current) {
if (isServerPath(url.pathname)) {
return null
}
- // -> Same page, different fragment: the browser scrolls and announces it, and the router would do
- // neither
+ // -> Same page, different fragment: nothing to route to, and `sameDocumentHash` handles the scroll
if (url.pathname === current.pathname && url.hash) {
return null
}
return `${url.pathname}${url.search}${url.hash}`
}
+
+/**
+ * The fragment of a link that points at a heading on the page already open, if that is what it is.
+ *
+ * The counterpart to `routableHref`, which declines these: there is no page to load, only a place on
+ * this one to travel to. Left to the browser it is an instant jump, where every other way of reaching
+ * a heading in this app animates — the contents list does, and so does arriving with a `#heading` in
+ * the URL.
+ *
+ * Declined on the same grounds as a routable link, so a fragment link asking for a new tab, or
+ * carrying `download` / `rel="external"`, is still the browser's to handle.
+ *
+ * @param {object} link The anchor's own properties: `href` is the resolved absolute URL.
+ * @param {Location|{origin: string, pathname: string}} current Where the reader is now.
+ * @returns {string|null} The `#fragment` to travel to, or null when this is not such a link.
+ */
+export function sameDocumentHash({ href, target, download, rel } = {}, current) {
+ if (!href || (target && target !== '_self') || download || /\bexternal\b/.test(rel ?? '')) {
+ return null
+ }
+
+ let url
+ try {
+ url = new URL(href)
+ } catch {
+ return null
+ }
+ if (url.origin !== current.origin || !url.hash || url.pathname !== current.pathname) {
+ return null
+ }
+
+ return url.hash
+}
diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue
index 9920b20b5..aedbec39f 100644
--- a/frontend/src/layouts/AdminLayout.vue
+++ b/frontend/src/layouts/AdminLayout.vue
@@ -497,8 +497,13 @@ const { t } = useI18n()
// META
-useMeta({
- titleTemplate: (title) => `${title} - ${t('admin.adminArea')} - Wiki.js`
+// -> The site's own name rather than the literal `Wiki.js`, as the page view does. A getter, so the
+// template is recomputed when the site config arrives -- see the note in `MainLayout`.
+useMeta(() => {
+ const siteTitle = siteStore.title
+ return {
+ titleTemplate: (title) => `${title} - ${t('admin.adminArea')} - ${siteTitle}`
+ }
})
// DATA
diff --git a/frontend/src/layouts/InboxLayout.vue b/frontend/src/layouts/InboxLayout.vue
index 4e217f365..c197db3be 100644
--- a/frontend/src/layouts/InboxLayout.vue
+++ b/frontend/src/layouts/InboxLayout.vue
@@ -36,6 +36,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useMeta } from '@/composables/meta'
+import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import HeaderNav from '@/components/HeaderNav.vue'
@@ -51,6 +52,7 @@ import MainOverlayDialog from '@/components/MainOverlayDialog.vue'
// STORES
+const siteStore = useSiteStore()
const userStore = useUserStore()
// ROUTER
@@ -64,8 +66,13 @@ const { t } = useI18n()
// META
-useMeta({
- titleTemplate: (title) => `${title} - ${t('inbox.title')} - Wiki.js`
+// -> The site's own name rather than the literal `Wiki.js`, as the page view does. A getter, so the
+// template is recomputed when the site config arrives -- see the note in `MainLayout`.
+useMeta(() => {
+ const siteTitle = siteStore.title
+ return {
+ titleTemplate: (title) => `${title} - ${t('inbox.title')} - ${siteTitle}`
+ }
})
// DATA
diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue
index 3857bf598..35b952923 100644
--- a/frontend/src/layouts/MainLayout.vue
+++ b/frontend/src/layouts/MainLayout.vue
@@ -156,8 +156,18 @@ const { t } = useI18n()
// META
-useMeta({
- titleTemplate: (title) => `${title} - ${siteStore.title}`
+/*
+ A getter that READS the site title, so `watchEffect` has something to track: the site config is
+ fetched, so a template closing over `siteStore.title` and registered once would keep whatever the
+ store held at mount. The page title alone no longer forces a recompute either, now that a page with
+ no title of its own -- the welcome screen, a path with no page -- has to fall back to the site name
+ rather than leaving the tab reading " - Site".
+*/
+useMeta(() => {
+ const siteTitle = siteStore.title
+ return {
+ titleTemplate: (title) => (title ? `${title} - ${siteTitle}` : siteTitle)
+ }
})
// REFS
diff --git a/frontend/src/layouts/ProfileLayout.vue b/frontend/src/layouts/ProfileLayout.vue
index 1131d5846..d3e00ef5b 100644
--- a/frontend/src/layouts/ProfileLayout.vue
+++ b/frontend/src/layouts/ProfileLayout.vue
@@ -86,8 +86,13 @@ const { t } = useI18n()
// META
-useMeta({
- titleTemplate: (title) => `${title} - ${t('profile.title')} - Wiki.js`
+// -> The site's own name rather than the literal `Wiki.js`, as the page view does. A getter, so the
+// template is recomputed when the site config arrives -- see the note in `MainLayout`.
+useMeta(() => {
+ const siteTitle = siteStore.title
+ return {
+ titleTemplate: (title) => `${title} - ${t('profile.title')} - ${siteTitle}`
+ }
})
// DATA
diff --git a/frontend/src/pages/Index.vue b/frontend/src/pages/Index.vue
index 04f4312f5..ac67f46a7 100644
--- a/frontend/src/pages/Index.vue
+++ b/frontend/src/pages/Index.vue
@@ -104,7 +104,7 @@
that is not there at all, has no target to have been given yet.
-->
-
+