diff --git a/AGENTS.md b/AGENTS.md
index 55ed548..68e69c1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -59,7 +59,11 @@ not for the public pages. See `docs/design-principles.md` for the design.
and embedded in page ETags so nav-affecting changes invalidate caches.
`Data.brand` is the site name (header link + `
` suffix), editable
in the site editor via `/_api/settings`; empty = no header link and
- no `` suffix.
+ no `` suffix. `Data.theme` is the active theme name (empty =
+ none/base only); themes live in `frontend/src/assets/themes/{theme}`.
+ `Data.custom_css` is raw trusted CSS injected inline in every page
+ `` (id `pagerite-user`) and swapped during fetch-navigation;
+ editable in the site editor.
- `markdown.py` — markdown-it-py renderer (html passthrough + attrs,
footnote, deflist, tasklists plugins). Custom image rule: relative srcs
resolve against the page path, titled images become figures.
@@ -83,8 +87,8 @@ not for the public pages. See `docs/design-principles.md` for the design.
- `assets/` — shared styles and data files built by Vite and served hashed
under `/_assets/`: `pagerite.css` (base layout + conservative variables),
`themes/purple/theme.css` (the purple/dark theme override, including its
- own `banner.svg`), `pygments.css`, and `fonts/` (self-hosted
- Fraunces/Literata/Fira Code variable woff2). The `::view-transition*` block at the end of `pagerite.css` (from
+ own `banner.svg`), `pygments.css`, and `fonts/` (self-hosted Source
+ Sans 3/Source Serif 4/Inter/Montserrat/Fira Code variable woff2). The `::view-transition*` block at the end of `pagerite.css` (from
termotohtori.fi) is fragile — do not tweak.
- Vite builds ES-module `.js` outputs; the backend renders `
@@ -605,7 +726,18 @@ onUnmounted(() => {
placeholder="Site name (header link and window title)"
@input="onBrandInput"
/>
+
+
@@ -727,6 +859,11 @@ onUnmounted(() => {
border-radius: 4px;
}
+.theme-select {
+ flex: 0 0 auto;
+ width: auto;
+}
+
/* Small CodeMirror window for the banner HTML; scrolls internally. */
.banner-cm {
border: 1px solid var(--line);
@@ -748,6 +885,26 @@ onUnmounted(() => {
display: none;
}
+/* CodeMirror window for site-wide custom CSS. */
+.css-cm {
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.css-cm :deep(.cm-editor) {
+ max-height: 12rem;
+ font-size: 0.85rem;
+}
+
+.css-cm :deep(.cm-scroller) {
+ overflow: auto;
+}
+
+.css-cm :deep(.cm-gutters) {
+ display: none;
+}
+
.structure {
flex: 1;
overflow-y: auto;
diff --git a/frontend/src/StructureTree.vue b/frontend/src/StructureTree.vue
index a45aed1..68b9e2a 100644
--- a/frontend/src/StructureTree.vue
+++ b/frontend/src/StructureTree.vue
@@ -274,7 +274,7 @@ body.tree-dragging .treelist {
}
.slug-edit {
- font-family: "Fira Code", monospace;
+ font-family: var(--font-code);
color: var(--muted);
}
diff --git a/frontend/src/assets/fonts/fonts.css b/frontend/src/assets/fonts/fonts.css
index 3c68159..4a9b568 100644
--- a/frontend/src/assets/fonts/fonts.css
+++ b/frontend/src/assets/fonts/fonts.css
@@ -1,3 +1,10 @@
-@font-face { font-family: 'Fraunces'; font-weight: 100 1000; font-style: normal; font-display: swap; src: url('fraunces.woff2') format('woff2'); }
-@font-face { font-family: 'Literata'; font-weight: 100 900; font-style: normal; font-display: swap; src: url('literata.woff2') format('woff2'); }
+/* Variable fonts: only downloaded when referenced by the page CSS. */
+@font-face { font-family: 'Inter'; font-weight: 100 900; font-style: normal; font-display: swap; src: url('inter.woff2') format('woff2'); }
+@font-face { font-family: 'Inter'; font-weight: 100 900; font-style: italic; font-display: swap; src: url('inter-italic.woff2') format('woff2'); }
+@font-face { font-family: 'Montserrat'; font-weight: 100 900; font-style: normal; font-display: swap; src: url('montserrat.woff2') format('woff2'); }
+@font-face { font-family: 'Montserrat'; font-weight: 100 900; font-style: italic; font-display: swap; src: url('montserrat-italic.woff2') format('woff2'); }
+@font-face { font-family: 'Source Sans 3'; font-weight: 200 900; font-style: normal; font-display: swap; src: url('sourcesans3.woff2') format('woff2'); }
+@font-face { font-family: 'Source Sans 3'; font-weight: 200 900; font-style: italic; font-display: swap; src: url('sourcesans3-italic.woff2') format('woff2'); }
+@font-face { font-family: 'Source Serif 4'; font-weight: 200 900; font-style: normal; font-display: swap; src: url('sourceserif4.woff2') format('woff2'); }
+@font-face { font-family: 'Source Serif 4'; font-weight: 200 900; font-style: italic; font-display: swap; src: url('sourceserif4-italic.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 300 700; font-style: normal; font-display: swap; src: url('firacode.woff2') format('woff2'); }
diff --git a/frontend/src/assets/fonts/fraunces.woff2 b/frontend/src/assets/fonts/fraunces.woff2
deleted file mode 100644
index cb295bf..0000000
Binary files a/frontend/src/assets/fonts/fraunces.woff2 and /dev/null differ
diff --git a/frontend/src/assets/fonts/inter-OFL.txt b/frontend/src/assets/fonts/inter-OFL.txt
new file mode 100644
index 0000000..40589da
--- /dev/null
+++ b/frontend/src/assets/fonts/inter-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/frontend/src/assets/fonts/inter-italic.woff2 b/frontend/src/assets/fonts/inter-italic.woff2
new file mode 100644
index 0000000..2f06c65
Binary files /dev/null and b/frontend/src/assets/fonts/inter-italic.woff2 differ
diff --git a/frontend/src/assets/fonts/inter.woff2 b/frontend/src/assets/fonts/inter.woff2
new file mode 100644
index 0000000..d15208d
Binary files /dev/null and b/frontend/src/assets/fonts/inter.woff2 differ
diff --git a/frontend/src/assets/fonts/literata.woff2 b/frontend/src/assets/fonts/literata.woff2
deleted file mode 100644
index cae7062..0000000
Binary files a/frontend/src/assets/fonts/literata.woff2 and /dev/null differ
diff --git a/frontend/src/assets/fonts/montserrat-OFL.txt b/frontend/src/assets/fonts/montserrat-OFL.txt
new file mode 100644
index 0000000..35ae34c
--- /dev/null
+++ b/frontend/src/assets/fonts/montserrat-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat) Montserrat-Italic[wght].ttf: Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/frontend/src/assets/fonts/montserrat-italic.woff2 b/frontend/src/assets/fonts/montserrat-italic.woff2
new file mode 100644
index 0000000..becdfd5
Binary files /dev/null and b/frontend/src/assets/fonts/montserrat-italic.woff2 differ
diff --git a/frontend/src/assets/fonts/montserrat.woff2 b/frontend/src/assets/fonts/montserrat.woff2
new file mode 100644
index 0000000..3b2eaa9
Binary files /dev/null and b/frontend/src/assets/fonts/montserrat.woff2 differ
diff --git a/frontend/src/assets/fonts/sourcesans3-OFL.txt b/frontend/src/assets/fonts/sourcesans3-OFL.txt
new file mode 100644
index 0000000..046fc66
--- /dev/null
+++ b/frontend/src/assets/fonts/sourcesans3-OFL.txt
@@ -0,0 +1,93 @@
+Google Inc.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/frontend/src/assets/fonts/sourcesans3-italic.woff2 b/frontend/src/assets/fonts/sourcesans3-italic.woff2
new file mode 100644
index 0000000..4b43ac3
Binary files /dev/null and b/frontend/src/assets/fonts/sourcesans3-italic.woff2 differ
diff --git a/frontend/src/assets/fonts/sourcesans3.woff2 b/frontend/src/assets/fonts/sourcesans3.woff2
new file mode 100644
index 0000000..864cc41
Binary files /dev/null and b/frontend/src/assets/fonts/sourcesans3.woff2 differ
diff --git a/frontend/src/assets/fonts/sourceserif4-OFL.txt b/frontend/src/assets/fonts/sourceserif4-OFL.txt
new file mode 100644
index 0000000..046fc66
--- /dev/null
+++ b/frontend/src/assets/fonts/sourceserif4-OFL.txt
@@ -0,0 +1,93 @@
+Google Inc.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/frontend/src/assets/fonts/sourceserif4-italic.woff2 b/frontend/src/assets/fonts/sourceserif4-italic.woff2
new file mode 100644
index 0000000..9425030
Binary files /dev/null and b/frontend/src/assets/fonts/sourceserif4-italic.woff2 differ
diff --git a/frontend/src/assets/fonts/sourceserif4.woff2 b/frontend/src/assets/fonts/sourceserif4.woff2
new file mode 100644
index 0000000..30dbe2d
Binary files /dev/null and b/frontend/src/assets/fonts/sourceserif4.woff2 differ
diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css
index 3c77383..2fb541a 100644
--- a/frontend/src/assets/pagerite.css
+++ b/frontend/src/assets/pagerite.css
@@ -12,6 +12,9 @@
--accent: #0056b3;
--accent2: #003d80;
--line: #0000001a;
+ --font-body: "Source Sans 3", system-ui, sans-serif;
+ --font-heading: "Source Serif 4", Georgia, serif;
+ --font-code: "Fira Code", ui-monospace, monospace;
/* Width of the docked editor panel (used both here for shifting the page
and in the Vue editor's own styles). */
--editor-w: min(46rem, 50vw);
@@ -26,9 +29,12 @@ html {
}
body {
- font-family: "Literata", Georgia, serif;
+ font-family: var(--font-body);
font-size: 1.05rem;
line-height: 1.65;
+ /* Tabular numerals wherever the active font supports them; avoids
+ numbers jumping in width as counters/values change. */
+ font-variant-numeric: tabular-nums;
margin: 0;
background: var(--bg);
color: var(--text);
@@ -79,7 +85,7 @@ body {
}
#brand {
- font-family: "Fraunces", serif;
+ font-family: var(--font-heading);
font-weight: 700;
font-size: 2.4rem;
text-decoration: none;
@@ -257,7 +263,7 @@ main {
article h1,
article h2,
article h3 {
- font-family: "Fraunces", serif;
+ font-family: var(--font-heading);
font-weight: 600;
line-height: 1.25;
}
@@ -463,7 +469,7 @@ pre:hover .copy,
}
code {
- font-family: "Fira Code", ui-monospace, monospace;
+ font-family: var(--font-code);
font-size: 0.88em;
}
diff --git a/frontend/src/assets/themes/purple/banner.svg b/frontend/src/assets/themes/purple/banner.svg
new file mode 100644
index 0000000..e93bc03
--- /dev/null
+++ b/frontend/src/assets/themes/purple/banner.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 25220a1..f3df462 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -5,8 +5,9 @@
// - SiteEditor ("site" mode): pen on the banner — banner HTML editing
// (previewed into the real banner) and the site structure tree.
if (import.meta.env.DEV) {
+ // Base styles only; the theme CSS is imported by pagerite.js (which
+ // always runs first — the editor opens from public pages).
import("./assets/pagerite.css");
- import("./assets/themes/purple/theme.css");
}
import { createApp } from 'vue'
@@ -29,6 +30,15 @@ export function openEditor(path, { mode = 'page' } = {}) {
pagePath: path,
onClose: closeEditor,
}).mount(host)
+ // The slide-in (editor-slide-in in pagerite.css) is a one-shot open
+ // effect; once finished, drop it so that later stylesheet swaps (theme
+ // change re-creating @keyframes) cannot restart it.
+ const root = host.firstElementChild
+ root?.addEventListener('animationend', function done(e) {
+ if (e.target !== root) return
+ root.removeEventListener('animationend', done)
+ root.style.animation = 'none'
+ })
}
export function closeEditor() {
diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js
index e430076..5996c8f 100644
--- a/frontend/src/pagerite.js
+++ b/frontend/src/pagerite.js
@@ -7,7 +7,10 @@
(() => {
if (import.meta.env.DEV) {
import("./assets/pagerite.css");
- import("./assets/themes/purple/theme.css");
+ // The theme is selectable; the backend names the active one in a meta
+ // tag (dev links no stylesheets — Vite injects them from JS).
+ const theme = document.querySelector('meta[name="pagerite:theme"]')?.content;
+ if (theme) import(/* @vite-ignore */ `./assets/themes/${theme}/theme.css`);
}
const REGIONS = ["page-banner", "nav", "sidebar", "main"];
@@ -188,6 +191,17 @@
const el = document.getElementById(id);
el.replaceWith(document.importNode(doc.getElementById(id), true));
}
+ // Site-wide custom CSS lives in and must be
+ // kept in sync across fetch-navigations.
+ const oldUserStyle = document.getElementById("pagerite-user");
+ const newUserStyle = doc.getElementById("pagerite-user");
+ if (oldUserStyle && newUserStyle) {
+ oldUserStyle.textContent = newUserStyle.textContent;
+ } else if (newUserStyle) {
+ document.head.appendChild(document.importNode(newUserStyle, true));
+ } else if (oldUserStyle) {
+ oldUserStyle.remove();
+ }
document.title = doc.title;
// Banners may contain scripts (canvas etc.), content pages may too.
runScripts(document.getElementById("page-banner"));
@@ -235,6 +249,7 @@
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = css;
+ link.dataset.pagerite = "editor-css";
document.head.append(link);
}
}
@@ -242,7 +257,7 @@
editorModule = import(/* @vite-ignore */ editBtn.dataset.editorSrc);
editorModule
.then((m) => m.openEditor(path, { mode }))
- .catch(() => {});
+ .catch((e) => console.error("editor load failed:", e));
return;
}
const a = ev.target.closest("a[href]");
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index a0dcf4e..36be43a 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -32,6 +32,10 @@ export default defineConfig({
manifest: true,
assetsDir: '_assets',
rollupOptions: {
+ // main.js is dynamic-imported by the public page (pagerite.js) for
+ // its openEditor/closeEditor exports — keep them in the bundle
+ // (app builds strip unused entry exports by default).
+ preserveEntrySignatures: 'exports-only',
input: {
main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
diff --git a/pagerite/app.py b/pagerite/app.py
index daa5fc8..ec33f42 100644
--- a/pagerite/app.py
+++ b/pagerite/app.py
@@ -281,14 +281,16 @@ async def update_structure(op: StructureOp) -> None:
@app.get("/_api/settings")
async def get_settings() -> dict[str, str]:
- """Site-wide settings (the brand text)."""
- return {"brand": data.brand}
+ """Site-wide settings (brand, theme and custom CSS)."""
+ return {"brand": data.brand, "theme": data.theme, "custom_css": data.custom_css}
class SettingsIn(BaseModel):
"""Payload for updating site-wide settings."""
brand: str
+ theme: str
+ custom_css: str
@app.put("/_api/settings", status_code=204)
@@ -296,6 +298,8 @@ async def put_settings(settings: SettingsIn) -> None:
"""Update site-wide settings; bumps the version so ETags invalidate."""
with kanta.transaction("update settings"):
data.brand = settings.brand
+ data.theme = settings.theme
+ data.custom_css = settings.custom_css
data.version += 1
@@ -570,7 +574,7 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
path = path.strip("/")
if path and _is_reserved(path):
# Reserved slug shape: never content — no tree lookup.
- return HTMLResponse(views.render_not_found(data.menu, path, data.brand), 404)
+ return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme), 404)
chain = resolve(data.menu, path)
node = chain[-1] if chain else None
if node is not None and node.published and node.content is not None:
@@ -580,17 +584,17 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
if request.headers.get("if-none-match") == etag:
return Response(status_code=304)
return HTMLResponse(
- views.render_page(data.menu, path, data.brand),
+ views.render_page(data.menu, path, data.brand, data.custom_css, data.theme),
headers={"etag": etag},
)
if node is not None and node.published and node.content is None:
# Category label without a landing page: placeholder with the pen
# to create it (404 — no page here, but the node is real).
- return HTMLResponse(views.render_category(data.menu, path, data.brand), 404)
+ return HTMLResponse(views.render_category(data.menu, path, data.brand, data.custom_css, data.theme), 404)
if node is None and not path:
# No front page (no top-level node with slug ""): "/" opens the
# first item of the navigation instead.
for slug, item in sorted_nodes(data.menu):
if item.published:
return RedirectResponse(f"/{slug}")
- return HTMLResponse(views.render_not_found(data.menu, path, data.brand), 404)
+ return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme), 404)
diff --git a/pagerite/data.py b/pagerite/data.py
index 10a2aa6..78e3ed3 100644
--- a/pagerite/data.py
+++ b/pagerite/data.py
@@ -79,6 +79,12 @@ class Data(msgspec.Struct):
#: Site name shown in the header and suffix; editable in the
#: site editor. Empty = no brand link in the header, no title suffix.
brand: str = "Pagerite"
+ #: Active theme name (empty = none/base only). Themes live in
+ #: frontend/src/assets/themes/{theme}/theme.css.
+ theme: str = "purple"
+ #: Raw site-wide custom CSS, injected inline in every page .
+ #: Trusted author content; not sanitized.
+ custom_css: str = ""
#: Legacy flat page store (pre-tree databases); migrated into `menu`
#: on startup, then cleared. Never written otherwise.
pages: dict[str, Page] = {}
diff --git a/pagerite/views.py b/pagerite/views.py
index 50071da..d9e2ed3 100644
--- a/pagerite/views.py
+++ b/pagerite/views.py
@@ -27,10 +27,8 @@ BUILD = Path(__file__).with_name("frontend-build")
# Shared CSS built as separate entries so the backend can link base and theme
# independently. Order matters: base first, theme overrides it.
-_SHARED_CSS = {
- "src/assets/pagerite.css": "pagerite",
- "src/assets/themes/purple/theme.css": "pagerite-theme",
-}
+_BASE_CSS_KEY = "src/assets/pagerite.css"
+_THEME_CSS_KEY = "src/assets/themes/{theme}/theme.css"
_manifest_cache: dict | None = None
_asset_cache: dict[str, tuple] = {}
@@ -43,7 +41,15 @@ def _manifest() -> dict:
return _manifest_cache
-def _shared_css_urls(vite_url: str | None) -> list[str]:
+def _css_keys(theme: str) -> list[str]:
+ """Manifest keys for the stylesheets to load for ``theme`` (empty = none)."""
+ keys = [_BASE_CSS_KEY]
+ if theme:
+ keys.append(_THEME_CSS_KEY.format(theme=theme))
+ return keys
+
+
+def _shared_css_urls(vite_url: str | None, theme: str) -> list[str]:
"""URLs for the base and theme stylesheets.
In dev the JS entries import these files, so Vite injects them; the
@@ -52,10 +58,10 @@ def _shared_css_urls(vite_url: str | None) -> list[str]:
if vite_url:
return []
manifest = _manifest()
- return [f"/{manifest[key]['file']}" for key in _SHARED_CSS]
+ return [f"/{manifest[key]['file']}" for key in _css_keys(theme)]
-def _editor_css_url(vite_url: str | None) -> str | None:
+def _editor_css_url(vite_url: str | None, theme: str) -> str | None:
"""URL for the editor-specific stylesheet (Vue component styles).
This is linked by the public-page edit pen so the editor styles are
@@ -65,24 +71,37 @@ def _editor_css_url(vite_url: str | None) -> str | None:
return None
manifest = _manifest()
entry = manifest["src/main.js"]
- shared_files = {manifest[key]["file"] for key in _SHARED_CSS}
+ shared_files = {manifest[key]["file"] for key in _css_keys(theme)}
for css in entry.get("css", []):
if css not in shared_files:
return f"/{css}"
return None
-def _layout(urls: list[str], modules: list[str] = ()) -> Template:
+def _layout(
+ urls: list[str],
+ modules: list[str] = (),
+ custom_css: str = "",
+ theme: str = "",
+) -> Template:
"""Page layout template with standard asset URLs and ES-module scripts.
Stylesheets use ``blocking="render"`` so the browser waits for them before
showing the page, avoiding a flash of unstyled content.
+
+ The active theme is named in a meta tag so that in dev (where the
+ backend links no stylesheets and Vite injects them from JS) the
+ frontend entries know which theme CSS module to import.
"""
doc = Document(E.Title, lang="en")
+ if theme:
+ doc.meta(name="pagerite:theme", content=theme)
for url in urls:
doc.link(rel="stylesheet", href=url, blocking="render")
for src in modules:
doc.script(src=src, type="module")
+ if custom_css.strip():
+ doc.style(custom_css, id="pagerite-user")
return Template(
doc
.header(
@@ -212,7 +231,7 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None:
return None
-def _edit_attrs(path: str, mode: str = "page") -> dict:
+def _edit_attrs(path: str, mode: str = "page", theme: str = "") -> dict:
"""Attributes for a 🖊️ edit button.
pagerite.js wires these buttons to dynamic-import the editor app
@@ -221,7 +240,7 @@ def _edit_attrs(path: str, mode: str = "page") -> dict:
(the pen on the banner) edits the banner and site structure. They are
buttons, not links: editing is an action, not a navigation.
"""
- script, editor_css = _editor_assets()
+ script, editor_css = _editor_assets(theme)
return {
"type": "button",
"class": "edit-link" if mode == "page" else "edit-link banner-edit-link",
@@ -247,25 +266,37 @@ def page_content(menu: dict[str, Node], path: str) -> HTML:
return HTML(str(doc))
-def render_page(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -> str:
+def render_page(
+ menu: dict[str, Node],
+ path: str,
+ brand: str = SITE_NAME,
+ custom_css: str = "",
+ theme: str = "",
+) -> str:
"""Render a full HTML page for the slug path."""
node = resolve(menu, path)[-1]
title = _title(path.rpartition("/")[2], node)
- scripts, styles = _page_assets()
+ scripts, styles = _page_assets(theme)
return str(
- _layout(styles, scripts)(
+ _layout(styles, scripts, custom_css, theme)(
Title=f"{title} – {brand}" if brand else title,
Brand=_brand_link(brand),
Nav=nav_html(menu, path),
Sidebar=sidebar_html(menu, path),
Banner=banner_html(menu, path),
- BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site")))),
+ BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site", theme)))),
Main=page_content(menu, path),
),
)
-def render_category(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -> str:
+def render_category(
+ menu: dict[str, Node],
+ path: str,
+ brand: str = SITE_NAME,
+ custom_css: str = "",
+ theme: str = "",
+) -> str:
"""Render the placeholder for a content-less category label (404).
The node exists in the tree but has no page of its own. Nav links
@@ -278,47 +309,53 @@ def render_category(menu: dict[str, Node], path: str, brand: str = SITE_NAME) ->
with doc:
doc.h1(title)
# Editing works here too: the pen creates this category's page.
- doc.button("🖊️", **_edit_attrs(path))
+ doc.button("🖊️", **_edit_attrs(path, "page", theme))
doc.p(
"Pages in this section are listed in the menu on the left."
)
- scripts, styles = _page_assets()
+ scripts, styles = _page_assets(theme)
return str(
- _layout(styles, scripts)(
+ _layout(styles, scripts, custom_css, theme)(
Title=f"{title} – {brand}" if brand else title,
Brand=_brand_link(brand),
Nav=nav_html(menu, path),
Sidebar=sidebar_html(menu, path),
Banner=banner_html(menu, path),
- BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site")))),
+ BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site", theme)))),
Main=HTML(str(doc)),
),
)
-def render_not_found(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -> str:
+def render_not_found(
+ menu: dict[str, Node],
+ path: str,
+ brand: str = SITE_NAME,
+ custom_css: str = "",
+ theme: str = "",
+) -> str:
"""Render a 404 page within the normal layout."""
doc = E.article
with doc:
doc.h1("Not Found")
# Editing works here too: this is how brand new pages get created.
- doc.button("🖊️", **_edit_attrs(path))
+ doc.button("🖊️", **_edit_attrs(path, "page", theme))
doc.p(f"No page at /{path}.")
- scripts, styles = _page_assets()
+ scripts, styles = _page_assets(theme)
return str(
- _layout(styles, scripts)(
+ _layout(styles, scripts, custom_css, theme)(
Title=f"Not Found – {brand}" if brand else "Not Found",
Brand=_brand_link(brand),
Nav=nav_html(menu, path),
Sidebar=sidebar_html(menu, path),
Banner=banner_html(menu, path),
- BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site")))),
+ BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site", theme)))),
Main=HTML(str(doc)),
),
)
-def _page_assets() -> tuple[list[str], list[str]]:
+def _page_assets(theme: str) -> tuple[list[str], list[str]]:
"""Script and CSS URLs for public pages (pagerite entry).
Dev mode loads the entry from the Vite dev server; production uses
@@ -326,18 +363,19 @@ def _page_assets() -> tuple[list[str], list[str]]:
"""
vite_url = os.environ.get("PAGERITE_VITE_URL")
if vite_url:
- return [f"{vite_url}/src/pagerite.js"], _shared_css_urls(vite_url)
- if "page" not in _asset_cache:
+ return [f"{vite_url}/src/pagerite.js"], _shared_css_urls(vite_url, theme)
+ key = f"page:{theme}"
+ if key not in _asset_cache:
manifest = _manifest()
entry = manifest["src/pagerite.js"]
- _asset_cache["page"] = (
+ _asset_cache[key] = (
[f"/{entry['file']}"],
- _shared_css_urls(None),
+ _shared_css_urls(None, theme),
)
- return _asset_cache["page"]
+ return _asset_cache[key]
-def _editor_assets() -> tuple[list[str], str | None]:
+def _editor_assets(theme: str) -> tuple[list[str], str | None]:
"""Script URL and editor-specific CSS URL for the public-page edit pen.
The shared CSS is already linked on the page, so the pen only needs the
@@ -346,8 +384,9 @@ def _editor_assets() -> tuple[list[str], str | None]:
vite_url = os.environ.get("PAGERITE_VITE_URL")
if vite_url:
return [f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"], None
- if "editor" not in _asset_cache:
+ key = f"editor:{theme}"
+ if key not in _asset_cache:
manifest = _manifest()
entry = manifest["src/main.js"]
- _asset_cache["editor"] = [f"/{entry['file']}"], _editor_css_url(None)
- return _asset_cache["editor"]
+ _asset_cache[key] = [f"/{entry['file']}"], _editor_css_url(None, theme)
+ return _asset_cache[key]