feat: serve sitemap.xml and robots.txt + headers

scarlett
NGPixel 8 hours ago
parent 32a656e7be
commit 8f72bd6d72
No known key found for this signature in database

@ -40,7 +40,7 @@ The backend is **TypeScript 7**; `frontend/` and `blocks/` are JavaScript. See
confirmation link or a password reset lands in a web inbox at `http://localhost:8025` rather than a
real mailbox. Point the wiki at it under **Admin → Mail** — host `localhost`, port 1025, TLS off,
no credentials.
- `localazy.json` — translation sync config; locale strings live in `backend/locales/`.
- Locale strings live in `backend/locales/`.
### `backend/`
@ -65,6 +65,10 @@ path in silence.
background) under `/_site`; `icons.ts` serves icons under `/_icons`, implementing the part of the
Iconify API protocol the frontend speaks (`/_icons/<prefix>.json?icons=a,b` and
`/_icons/<prefix>/<name>.svg`). Public and cached hard — see [Icons](#icons).
`rootFiles.ts` is the exception that registers at the root rather than under a prefix: `robots.txt`
and `sitemap.xml`, the two names in `RESERVED_ROOT_FILES` a crawler asks for by convention, both
driven by the site's **General → SEO** settings. The sitemap lists what the GUESTS group may read
and nothing else.
- `core/` — long-lived singletons: `config.ts` (yml + db-backed settings), `db.ts` (pg pool, Drizzle
instance, migrations, LISTEN/NOTIFY pubsub), `logger.ts`, `scheduler.ts` (poolifier thread pool +
postgres-backed job queue).
@ -86,7 +90,7 @@ path in silence.
file-tree half of the storage modules that address content by path (see [Storage targets](#storage-targets)).
- `types/` — ambient declarations: `global.d.ts` (the `WIKI` global) and `fastify.d.ts` (session +
route-permission augmentations).
- `locales/``en.json` source strings (Localazy-managed) + `metadata.js` language table (the one
- `locales/``en.json` source strings (CrowdIn-managed) + `metadata.js` language table (the one
remaining JavaScript file; typed by its sibling `metadata.d.ts`).
### `frontend/`
@ -245,8 +249,7 @@ tracked and will turn up in the next commit.
## TypeScript (backend)
The backend is entirely **TypeScript 7** (the native Go compiler — `tsc` is a platform binary, not a
JS bundle). The only remaining `.js` is `locales/metadata.js`, which is Localazy-generated output and
is typed by a sibling `locales/metadata.d.ts`.
JS bundle).
**There is no build step.** Node 26 runs `.ts` files directly by stripping types at load time, so
`node backend` and nodemon keep working unchanged as files are converted. `tsc` is used purely as a

@ -0,0 +1,263 @@
import { chunk } from 'es-toolkit/array'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import type { SitemapPage } from '../models/pages.ts'
/**
* The sitemap protocol's own ceiling: 50,000 URLs, and 50MB uncompressed, per file. Past it the
* document served is a sitemap index naming numbered parts of this size instead.
*/
const MAX_URLS_PER_FILE = 50000
/**
* How long a crawler or anything caching in front of this may hold one of these files.
*
* `public` whoever asked: neither document varies by requester. The sitemap is `listForSitemap`'s
* answer, which is the guests group's view and nobody else's, and robots.txt is a site setting so a
* shared cache has nothing to leak from one reader to another. Ten minutes because the alternative to
* a slightly stale sitemap is re-reading every page of a wiki for a file a crawler fetches a few
* times a day.
*/
const ROOT_FILE_CACHE = 'public, max-age=600'
/** Everything written into the document goes through here, including anything that came off a header. */
function xmlEscape(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;')
}
/**
* The origin a `<loc>` or robots.txt's `Sitemap:` line is written against.
*
* The requester's own, and deliberately not the site's configured hostname: a sitemap may only list
* URLs on the host it was itself fetched from a crawler discards the rest as a cross-submission
* so the host in the request IS the answer, whether the site is bound to it or is the catch-all `*`.
* It comes off a header and is therefore whatever the client said, which is why it is escaped before
* it reaches the XML. It cannot carry a line break into robots.txt: a header value holding one is
* rejected by the HTTP parser long before this.
*/
function originOf(req: FastifyRequest): string {
return `${req.protocol}://${req.host}`
}
/**
* Where a page is, absolute and ready to be written into an element.
*
* `urlFor` is what decides the path, the same as for every link the wiki makes of its own pages, so
* the locale prefix is bracketed exactly where that site's settings put one. A page path is held to
* `[a-zA-Z0-9-_/]` when it is saved, but a locale's short code is an administrator's to alias, so the
* result is percent-encoded before it is escaped.
*/
function locOf(origin: string, siteId: string, page: SitemapPage): string {
return xmlEscape(
`${origin}${encodeURI(WIKI.models.pages.urlFor(siteId, page.locale, page.path))}`
)
}
/** W3C datetime, which is what `<lastmod>` takes. Seconds: a crawler has no use for the nanoseconds. */
function lastmodOf(date: Date): string {
return date.toTemporalInstant().toString({ smallestUnit: 'second' })
}
/**
* The translations of each page, keyed by locale group.
*
* Built over the WHOLE list rather than the part being rendered, so that a page whose other languages
* fell into a different numbered file still names all of them. A page with no counterparts has a null
* group and is absent from here nulls are distinct, so they would otherwise all be one group.
*/
function localeGroupsOf(pages: SitemapPage[]): Map<string, SitemapPage[]> {
const groups = new Map<string, SitemapPage[]>()
for (const page of pages) {
if (!page.localeGroupId) {
continue
}
const group = groups.get(page.localeGroupId)
if (group) {
group.push(page)
} else {
groups.set(page.localeGroupId, [page])
}
}
return groups
}
/**
* One file of page URLs.
*
* `changefreq` and `priority` are deliberately absent: no search engine has read either for years,
* and the wiki has nothing honest to put in them every page would claim the same numbers.
*
* What is here instead is `xhtml:link`, one per language a page exists in, which is how a crawler is
* told that two paths are the same page rather than duplicates of each other. Emitted only where a
* page actually has counterparts, and including the page itself, which is what the annotation calls
* for. Only counterparts that are in this document at all: a translation the guests group may not
* read is not an alternate a crawler should be sent to.
*/
function renderUrlset(
origin: string,
siteId: string,
pages: SitemapPage[],
all: SitemapPage[]
): string {
const groups = localeGroupsOf(all)
const lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">'
]
for (const page of pages) {
lines.push(' <url>')
lines.push(` <loc>${locOf(origin, siteId, page)}</loc>`)
lines.push(` <lastmod>${lastmodOf(page.updatedAt)}</lastmod>`)
const alternates = page.localeGroupId ? (groups.get(page.localeGroupId) ?? []) : []
if (alternates.length > 1) {
for (const alternate of alternates) {
lines.push(
` <xhtml:link rel="alternate" hreflang="${xmlEscape(alternate.locale)}" href="${locOf(origin, siteId, alternate)}"/>`
)
}
}
lines.push(' </url>')
}
lines.push('</urlset>')
return `${lines.join('\n')}\n`
}
/**
* The index served in place of the list once it no longer fits in one file.
*
* The parts are `?p=N` on this same path rather than files of their own, because a sitemap may only
* list URLs at or below its own directory a part under `/_sitemap/` could name nothing outside it
* and because the reserved root files a crawler may ask for are a fixed set that no numbered name
* could join. A query string is a URL like any other to a crawler.
*/
function renderIndex(origin: string, parts: SitemapPage[][]): string {
const lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
]
parts.forEach((pages, index) => {
const newest = pages.reduce<Date | null>(
(latest, page) => (!latest || page.updatedAt > latest ? page.updatedAt : latest),
null
)
lines.push(' <sitemap>')
lines.push(` <loc>${xmlEscape(`${origin}/sitemap.xml?p=${index + 1}`)}</loc>`)
if (newest) {
lines.push(` <lastmod>${lastmodOf(newest)}</lastmod>`)
}
lines.push(' </sitemap>')
})
lines.push('</sitemapindex>')
return `${lines.join('\n')}\n`
}
function sendXml(reply: FastifyReply, body: string) {
return reply
.header('Cache-Control', ROOT_FILE_CACHE)
.type('application/xml; charset=utf-8')
.send(body)
}
/**
* Root file routes: `robots.txt` and `sitemap.xml`.
*
* The two files a crawler asks for at the root by convention rather than because the wiki has a page
* there which is why they are registered at the root rather than under one of the server's own
* prefixes, and why `RESERVED_ROOT_FILES` holds both names: without that, the SEO hook would treat
* `/sitemap.xml` as a page path and send a crawler off to the site's locale prefix, and `/robots.txt`
* would be redirected to the page `robots`, `txt` being a page extension on a default site.
* (`favicon.ico` is the third such name and is served by `@fastify/favicon`.)
*
* Both are public, both answer for whichever site the request's host resolves to, and both are driven
* by that site's own settings in the admin area's **General SEO** card.
*/
async function routes(app: FastifyInstance) {
/**
* robots.txt
*
* Two of the three settings on that card are about crawlers, and robots.txt can carry exactly one
* of them:
*
* - **Allow Indexing** off becomes `Disallow: /`, which is as close as this file gets. Worth being
* clear that it is not the same instruction: `Disallow` says do not CRAWL, `noindex` says do not
* INDEX, and a page that is never crawled can still be listed from its inbound links alone. The
* thorough form of the setting is the `X-Robots-Tag` on every HTML response `robotsTagFor` in
* `index.ts` and this line is the coarse one that keeps a crawler off the wiki to begin with.
* - **Allow Follow** has no expression here at all: `nofollow` is a directive about a document a
* crawler is holding, and robots.txt has no concept of one. That toggle is honoured by the same
* header, and this route does not pretend to carry it.
* - **Allow Sitemap** adds the `Sitemap:` line, which is how a crawler that was given nothing but a
* hostname finds the sitemap at all. Omitted when indexing is off, since pointing a crawler at an
* index of pages it has just been told not to crawl says nothing coherent.
*
* Nothing per-page is ever written here not the pages the guests group may not read, and not the
* ones marked out of search results. robots.txt is world-readable, so a `Disallow` naming a path is
* a published list of what a wiki considers worth hiding. What keeps those out of a crawler's way is
* that they are absent from the sitemap and refused when asked for.
*
* Served whatever the settings say there is no toggle for having a robots.txt, because a site
* always has an answer to the question it asks. Only a host that resolves to no site at all 404s.
*/
app.get('/robots.txt', async (req, reply) => {
const site = await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
if (!site) {
return reply.notFound()
}
const lines = ['User-agent: *']
if (site.config?.robots?.index) {
lines.push('Allow: /')
if (site.config.sitemap) {
lines.push('', `Sitemap: ${originOf(req)}/sitemap.xml`)
}
} else {
lines.push('Disallow: /')
}
return reply
.header('Cache-Control', ROOT_FILE_CACHE)
.type('text/plain; charset=utf-8')
.send(`${lines.join('\n')}\n`)
})
/**
* sitemap.xml
*
* Off for a site whose **Allow Sitemap** is unticked, on for a new one.
*/
app.get<{ Querystring: { p?: string } }>('/sitemap.xml', async (req, reply) => {
const site = await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
if (!site?.config?.sitemap) {
// -> A site with the setting off has no sitemap rather than an empty one, and 404 is what tells
// a crawler to stop asking for it
return reply.notFound()
}
const pages = await WIKI.models.pages.listForSitemap(site.id)
const origin = originOf(req)
// -> A site with nothing a guest may read still answers, with an empty list: it is the truthful
// answer, and a 404 would read as the feature being broken rather than as the wiki being shut
const parts = pages.length > 0 ? chunk(pages, MAX_URLS_PER_FILE) : [[]]
if (req.query.p === undefined) {
return sendXml(
reply,
parts.length > 1
? renderIndex(origin, parts)
: renderUrlset(origin, site.id, parts[0]!, pages)
)
}
const part = Number.parseInt(req.query.p, 10)
if (!Number.isInteger(part) || part < 1 || part > parts.length) {
return reply.notFound()
}
return sendXml(reply, renderUrlset(origin, site.id, parts[part - 1]!, pages))
})
}
export default routes

@ -97,6 +97,33 @@ function isPageUrl(urlPath: string): boolean {
return !firstSegment.startsWith('_') && !RESERVED_ROOT_FILES.has(firstSegment.toLowerCase())
}
/**
* What a site tells a crawler about a document it has already fetched, as an `X-Robots-Tag` value.
*
* The other half of the **General SEO** settings, and the half that carries what robots.txt cannot:
* that file can only say whether to CRAWL a path (see `controllers/rootFiles.ts`), while `noindex`
* and `nofollow` are instructions about a document in hand. A search engine reads this header exactly
* as it reads a `<meta name="robots">` tag, and unlike a tag the frontend would set once it booted
* it is there for a crawler that does not run the page's JavaScript.
*
* `noindex` is also the setting's only thorough form. `Disallow: /` keeps a crawler off the page, but
* a page nobody fetched can still be listed from its inbound links alone; this is what says not to
* list it.
*
* Null when both settings are on, which is every crawler's default anyway: no header says the same
* thing as `index, follow`, and a wiki that wants to be found should not have to repeat it on every
* response.
*/
function robotsTagFor(siteId: string | undefined): string | null {
// -> A host matching no site at all is still handed the app shell, and is told not to index it:
// there is no site here whose settings could say otherwise
const robots = siteId ? WIKI.sites[siteId]?.config?.robots : undefined
if (robots?.index && robots.follow) {
return null
}
return `${robots?.index ? 'index' : 'noindex'}, ${robots?.follow ? 'follow' : 'nofollow'}`
}
/**
* The segments a site's locale-prefixed URLs may start with, mapped to the locale each names.
*
@ -764,6 +791,11 @@ async function initHTTPServer() {
app.register(import('./controllers/thumb.ts'), { prefix: '/_thumb' })
app.register(import('./controllers/user.ts'), { prefix: '/_user' })
// -> At the root and with no prefix of their own: `robots.txt` and `sitemap.xml` are names a crawler
// asks for by convention, the same way `favicon.ico` is, and `RESERVED_ROOT_FILES` is what keeps
// the SEO hook above from mistaking either for a page path
app.register(import('./controllers/rootFiles.ts'))
// ----------------------------------------
// App Shell
// ----------------------------------------
@ -796,6 +828,17 @@ async function initHTTPServer() {
}
try {
const shell = await readFile(appShellPath, 'utf8')
/*
Every HTML document this wiki serves leaves through here a page and an app route alike so
this is the one place the site's indexing settings can be attached to all of them.
Straight off the site caches for the same reason the SEO hook above reads them that way: both
lookups are what `getSiteByHostname` would do, minus its optional reload.
*/
const robotsTag = robotsTagFor(WIKI.sitesMappings[req.hostname] || WIKI.sitesMappings['*'])
if (robotsTag) {
reply.header('X-Robots-Tag', robotsTag)
}
return reply.header('Cache-Control', 'no-store').type('text/html; charset=utf-8').send(shell)
} catch (err: any) {
// -> Nothing to serve means the frontend was never built, which is a setup step rather than a

@ -506,9 +506,9 @@
"admin.general.reasonForChangeRequired": "Required",
"admin.general.saveSuccess": "Site configuration saved successfully.",
"admin.general.searchAllowFollow": "Allow Search Engines to Follow Links",
"admin.general.searchAllowFollowHint": "This sets the meta-robots property to follow or nofollow.",
"admin.general.searchAllowFollowHint": "When off, pages are served with a nofollow header, asking search engines not to follow the links they contain.",
"admin.general.searchAllowIndexing": "Allow Indexing by Search Engines",
"admin.general.searchAllowIndexingHint": "This sets the meta-robots property to index or noindex.",
"admin.general.searchAllowIndexingHint": "When off, pages are served with a noindex header and robots.txt disallows crawling, keeping the site out of search results.",
"admin.general.senderEmailHint": "Email address of the sender.",
"admin.general.senderNameHint": "Name of the sender.",
"admin.general.siteBranding": "Site Branding",

@ -158,6 +158,11 @@ class Groups {
for (const row of rows) {
rulesCache[row.id] = (row.rules ?? []) as GroupRule[]
}
// -> The sitemap is a list of what the GUESTS group may read, held for minutes at a time. Every
// other consumer of these rules asks per request and is correct the moment this returns; that
// one would go on publishing paths a rule had just taken away, which is a permission waiting
// for a timer rather than a document being a little out of date
WIKI.models.pages.invalidateSitemaps()
WIKI.logger.info(`Loaded page rules for ${rows.length} groups [ OK ]`)
}

@ -1,4 +1,4 @@
import { and, desc, eq, inArray, ne, notInArray, sql } from 'drizzle-orm'
import { and, desc, eq, inArray, isNull, ne, notInArray, sql } from 'drizzle-orm'
import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts'
import {
CustomError,
@ -67,6 +67,30 @@ export function pageEditorForExtension(ext: string): string | null {
*/
const REDIRECT_EDITOR = 'redirect'
/**
* How long a site's sitemap list is held before it is read again, in seconds.
*
* The same figure as the `Cache-Control` the file goes out with, for the same reason: a sitemap may
* be a few minutes out of date without anybody being misled. Note the two compound see
* `listForSitemap`.
*/
const SITEMAP_TTL = 600
/** Namespaced so that `invalidateSitemaps` can find every site's entry without knowing the sites. */
const SITEMAP_CACHE_PREFIX = 'sitemap:'
function sitemapCacheKey(siteId: string): string {
return `${SITEMAP_CACHE_PREFIX}${siteId}`
}
/**
* Sitemap lists being built right now, one at most per site.
*
* Deliberately NOT in `WIKI.cache`: a promise is this process's, and the point of it is to make
* concurrent callers in this process wait on one query rather than start their own.
*/
const sitemapBuilds = new Map<string, Promise<SitemapPage[]>>()
/** A page path is what ends up in a URL, so it is held to what reads and routes cleanly. */
const rePagePath = /^[a-zA-Z0-9-_/]*$/
const reAlias = /^[a-zA-Z0-9-_]*$/
@ -233,6 +257,15 @@ export interface RecentPage {
authorName: string | null
}
/** A page as a sitemap entry sees it: where it is, when it last changed, and what it is a translation of. */
export interface SitemapPage {
locale: string
path: string
updatedAt: Date
/** The set of pages this one is a translation of, or null when it has none — see the column. */
localeGroupId: string | null
}
export interface PageActor {
id: string
permissions: string[]
@ -800,6 +833,118 @@ class Pages {
}))
}
/**
* Every page of a site that belongs in its sitemap, cached.
*
* The list is what a sitemap costs: one read of every published page of a site, and then the guests
* group's rules resolved against each of them. That is far too much to spend per request on a
* document whose whole audience is a handful of crawlers, so it is built on demand and held for
* `SITEMAP_TTL` seconds.
*
* **The LIST is what is cached, not the file.** Rendering reads three things that a cached file
* would freeze: the request's own origin, which differs between the hosts a catch-all site answers
* on; `urlFor`, which brackets a path by locale exactly as that site's settings say; and whether the
* sitemap is enabled at all. Keeping those per request means a change to any of them shows up at
* once, that paging re-slices one list instead of caching every part separately, and that a site
* reached on several hostnames needs one entry rather than one per host.
*
* It is not the smaller of the two, mind: measured at about **275 bytes per page** roughly 2.7 MB
* for a 10,000-page site where the rendered XML for the same pages is nearer 1.5 MB, since a long
* one-byte string costs less than an object with four fields and a `Date`. The reasons above are
* what pay for the difference.
*
* What does wait for the TTL is a page appearing, changing or going. That is the deliberate trade
* the file is already served `max-age=600`, so a crawler may be holding one that old regardless, and
* a sitemap is a hint about where to look rather than a statement of record. The two compound: a
* crawler can be reading a list up to twice the TTL old. Admin System's **Flush Cache** empties
* this with everything else, and does it across an HA set, for when that is not good enough.
*
* @see `models/groups.ts` `reloadCache`, which drops this when a group's rules change the one
* input where being out of date is a permissions question rather than a freshness one.
*/
async listForSitemap(siteId: string): Promise<SitemapPage[]> {
const cached = WIKI.cache.get<SitemapPage[]>(sitemapCacheKey(siteId))
if (cached) {
return cached
}
/*
A burst against a cold cache is one query and not one per request. Without this the endpoint is
public, unauthenticated and trivially made to stack full table scans which is most of what the
cache is here to prevent, and exactly when it is not yet populated.
*/
const inFlight = sitemapBuilds.get(siteId)
if (inFlight) {
return inFlight
}
const build = this.buildSitemapList(siteId).finally(() => sitemapBuilds.delete(siteId))
sitemapBuilds.set(siteId, build)
return build
}
/** Drop every site's cached sitemap list, for a change that could have altered any of them. */
invalidateSitemaps(): void {
WIKI.cache.del(WIKI.cache.keys().filter((key) => key.startsWith(SITEMAP_CACHE_PREFIX)))
}
/**
* The read behind `listForSitemap`, which is the only thing that should call it.
*
* A sitemap is a list handed to search engines, so the question is not what exists but what a
* crawler may both reach and index. Four things decide it, three of them in SQL:
*
* - **Published**, on the same reading as everywhere else a `scheduled` page is not published yet
* whatever its dates say, and a draft never was.
* - **Not a redirection**, which has no body to index and sends its reader elsewhere anyway.
* - **Not password protected**, since what a crawler would reach there is the lock screen.
* - **`isSearchable`**, the page property whose whole purpose is keeping a page out of search
* results. A sitemap is the most direct way there is of putting one in them.
*
* The fourth is the guests group's page rules, which no query can express they match on path,
* locale and tags, and are resolved a page at a time so the candidates are filtered here. The
* guests group and nothing else: this document is the same for whoever asks, and what the public
* may read is exactly what that group's rules say. An administrator fetching it therefore gets the
* file a crawler would, which is also what makes it safe to cache publicly.
*/
private async buildSitemapList(siteId: string): Promise<SitemapPage[]> {
const rows = await WIKI.db
.select({
locale: pagesTable.locale,
path: pagesTable.path,
tags: pagesTable.tags,
updatedAt: pagesTable.updatedAt,
localeGroupId: pagesTable.localeGroupId
})
.from(pagesTable)
.where(
and(
eq(pagesTable.siteId, siteId),
eq(pagesTable.publishState, 'published'),
eq(pagesTable.isSearchable, true),
ne(pagesTable.editor, REDIRECT_EDITOR),
isNull(pagesTable.password)
)
)
// -> A stable order, so that a sitemap split into numbered parts means the same thing on the
// next fetch as it did on the one that handed the crawler those numbers
.orderBy(pagesTable.locale, pagesTable.path)
// -> No group-wide permissions rather than the guests group's own: `checkAccess` reads that list
// only for `manage:system`, and a wiki that had somehow handed the public an administrator's
// permission should not also publish an index of every page it has as a consequence
const guests = { groupIds: [WIKI.data.systemIds.guestsGroupId], permissions: [] }
const pages = rows
.filter((row) => WIKI.models.groups.checkAccess(guests, 'read:pages', row))
.map(({ locale, path, updatedAt, localeGroupId }) => ({
locale,
path,
updatedAt,
localeGroupId
}))
WIKI.cache.set(sitemapCacheKey(siteId), pages, SITEMAP_TTL)
return pages
}
/**
* Where a page lives, as a path.
*

@ -83,6 +83,14 @@ class Sites {
for (const site of sites) {
WIKI.sitesMappings[site.hostname] = site.id
}
/*
Sitemap lists are held per site for minutes at a time, and `WIKI.cache` has no expiry sweeper
an entry is only dropped when its own key is next read. So a site that was deleted, or whose
sitemap was just switched off, would hold its last list for the life of the process: nothing
will ever ask for that key again. This is the one place every create, update and delete passes
through, which makes it the place to let them go.
*/
WIKI.models.pages.invalidateSitemaps()
WIKI.logger.info(`Loaded ${sites.length} site configurations [ OK ]`)
}

@ -8,15 +8,6 @@
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">
<title>Wiki.js</title>
<!--preload-links-->
<!--
The app's only webfont, and self-hosted: Roboto 300-900 as woff2, covering latin, latin-ext,
greek, cyrillic and vietnamese. Icon data is inlined at build time by scripts/generate-icons.mjs,
so there is no icon webfont to load.
Declared here rather than imported from a package, which is what `@quasar/extras/roboto-font`
used to do from main.js. Bundled CSS is linked after this, so those rules won -- leaving the app
on a latin-only woff copy of the same family while these files went unfetched.
-->
<link href="/_assets/fonts/roboto/roboto.css" rel="stylesheet" />
<style type="text/css">
@keyframes initspinner {

Loading…
Cancel
Save