Compare commits

...
19 Commits
Author SHA1 Message Date
Jimmy Cai 03bf8b0c3f release: 4.0.2 2026-05-03 22:52:01 +02:00
powerfullzandGitHub e294e4ed70 fix: Firefox fallback breaks right-edge fade in related content (#1332)
* fix: Firefox fallback breaks right-edge fade in related content (#133X)

Firefox does not support scroll-driven animations by default, causing
the animation to fall back to a 0s time-based animation that jumps to
the last keyframe — flipping the fade from right to left.

Wrap animation properties in @supports (animation-timeline: scroll())
so Firefox degrades gracefully to a static right-edge fade.

* chore: fix identation

* fix: add back wrongly deleted props
2026-05-03 22:50:35 +02:00
d3eacc9daf fix: Hugo language related deprecation (v0.158.0+) (#1334)
* language related deprecation fix

Reference: https://github.com/gohugoio/hugo/releases/tag/v0.158.0
.Site.LanguageCode -> .Site.Language.Locale
.Language.LanguageDirection -> .Language.Direction
.Language.LanguageCode -> .Language.Locale

Also, in the configuration hugo.toml, the user should change `languageCode` to `locale`.

* Update languageCode to locale in configuration

* fix(languages): update languageName to label in languages.toml

* .Language.LanguageName -> .Language.Label

* .Language.Lang -> .Language.Name

---------

Co-authored-by: Jimmy Cai <jimmy@cai.im>
2026-05-03 22:49:42 +02:00
Jimmy Cai c0f57bab7a release: 4.0.1 2026-04-26 20:21:35 +02:00
JimmyandGitHub 752fd5805f fix(image): correct permalink handling for images placed in static folder (#1330)
* fix(image): correct permalink handling for images placed in `static` folder

related: https://github.com/CaiJimmy/hugo-theme-stack/discussions/1307

* Remove urls.AbsURL
2026-04-26 20:20:51 +02:00
0fc8264927 fix: comment scripts bypass cookie consent (#1308)
* fix: comment scripts bypass cookie consent

When GDPR cookie consent is enabled, the comments container is simply
hidden with CSS. However, this still allows all third-party script
tags within the container to be evaluated and downloaded by the
browser.

This PR uses the <template> tag which stops inner scripts from evaluating.
Once consent is granted, the template content is cloned and inserted
into the container, and the scripts are manually loaded in order.

* refactor(comments): move consent-gated loader to TS asset

* fix(comments): add integrity attribute to comments consent script

* style: align comments consent docs and naming

* refactor(comments): simplify script handling

---------

Co-authored-by: Jimmy Cai <jimmy@cai.im>
2026-04-26 18:51:22 +02:00
55340540b5 feat: support Artalk comment system (#1306)
* feat: support artalk comment system

* chore: change url from testingcf to cdn prefix

* refactor(comments): align artalk provider style and keep custom css overrides

* fix: update Artalk CDN links to use the correct URL

* refactor(comments): simplify Artalk implementation

* fix(artalk): update script tag to use ESM for Artalk

* fix(artalk): set locale to 'auto' for Artalk comments

* refactor: move artalk style and script link to external.toml

---------

Co-authored-by: Jimmy Cai <jimmy@cai.im>
2026-04-26 18:05:54 +02:00
powerfullzandGitHub df9253a8e9 fix(cookies): avoid banner wrapper blocking clicks (#1327) 2026-04-26 15:47:55 +02:00
96202fea2b accessibility: fix prohibited ARIA attributes for disabled previous page & next page elements (#1312)
accessibility: fix prohibited ARIA attributes for previous and next page button, if they are disabled

Co-authored-by: Jimmy Cai <jimmy@cai.im>
2026-04-26 15:42:06 +02:00
powerfullzandGitHub e55168d873 fix: optimize code block selection experience (#1311) 2026-04-26 13:50:19 +02:00
Jimmy Cai e0418efe2f chore: update Hugo version to 0.160.1 in build script 2026-04-26 13:43:09 +02:00
JimmyandGitHub 08a8b5d767 feat: codeblock improvements (#1328)
* fix: do not show codeblock copy button if clipboard api is not available

related: https://github.com/CaiJimmy/hugo-theme-stack/issues/1309

* fix: remove duplicated codeblock script in main.ts

* feat: add i18n support to codeblock copy button

* feat: add translation strings for codeblock copy button
2026-04-26 13:40:46 +02:00
powerfullzandGitHub 9ddd11797b fix: replace deprecated .Site.Data with hugo.Data (#1322)
fix: replace deprecated .Site.Data with hugo.Data

Use hugo.Data instead of deprecated .Site.Data (Hugo v0.156.0+)
2026-04-26 13:16:38 +02:00
powerfullzandGitHub 6f3ab760f9 fix: correct permalink casing for OpenGraph and Twitter image tags (#1324) 2026-04-26 13:14:34 +02:00
4f2764e0a5 fix: related content overflow and horizontal scroll issues (#1305)
* fix: resolve related content card overflow and page horizontal scroll

* fix: prevent related content overflow on mobile while preserving shadow space

* fix: align related content cards and prevent shadow clipping

Expands the left margin of the related content carousel to match the main article, preserving the full card shadow. Adds a visual mask gradient to gracefully fade out the right edge.

* feat: add mask image also to left side of subsection list, which is activated when scrolling

* feat: fade out right mask image near the end of horizontal scroll

---------

Co-authored-by: Jimmy Cai <jimmy@cai.im>
2026-04-26 13:12:49 +02:00
37f6a77a1a feat: Add jump-to-page dialog and improve pagination logic and styling (#1303)
* feat: add jump-to-page dialog on pagination ellipsis

- Replace pagination windowing algorithm with a tighter 5-item window
  (curr-1 / curr / curr+1, clamped at edges) and hide-on-mobile for
  pages more than 1 step away from the current page
- Ellipsis spans now carry the pagination-jump-trigger class and open
  a native <dialog> on click instead of being static decorations
- Add <dialog id="pagination-jump-dialog"> with number input, Go button,
  and Enter-key hint; data-total / data-first-url / data-format-url
  attributes on <nav> give the JS all it needs to build the target URL
- Add assets/ts/pagination.ts: setupPaginationJump() with Fisher-Yates
  backdrop-click and ESC handling, animated open/close via .closing class
- Wire setupPaginationJump() into Stack.init() in main.ts
- Extend pagination.scss with .hide-on-mobile, .pagination-jump-trigger
  hover style, and the full #pagination-jump-dialog styling + keyframe
  scale-up/scale-down animations
- Add [pagination] i18n keys (jumpToPage / jump / pressEnter) to
  en / zh / zh-hant-tw / zh-hant-hk / ja

* style: align pagination code with upstream conventions

- pagination.html: collapse multi-line span/class/aria attrs to single
  lines; use HTML5 void element syntax; fix hint div indentation
- pagination.scss: 2-space indent → 4-space indent
- pagination.ts: tab indent → 4-space indent; export const arrow fn →
  export function declaration

* chore: update layouts/_partials/pagination.html

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: add aria-labelledby attrributes for better accesibility

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: use { once: true } for the listener and guard against re-entry when .closing is already set.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* style: replace hardcoded values with design tokens and improve dialog UX

- Replace all hardcoded px spacing in .page-link with --spacing-* tokens
- Use @include card-style in #pagination-jump-dialog; override with --shadow-l2
- Replace padding: 24px with var(--card-padding) for responsive dialog padding
- Align header title/subtitle to space-between for cleaner layout
- Replace hardcoded gaps/padding in form with spacing tokens
- Fix input/button border-radius from 8px to 6px (better proportion for 44px height)
- Fix focus ring: use color-mix(accent-color 20%) so it's visible in dark mode
- Add :focus-visible + outline:none to fully suppress native browser outline
- Remove translateY from button hover for more restrained interaction
- Scope transition to specific properties (border-color, box-shadow, background-color)

* chore: add pagination test articles for easier debugging

* style: enhance backdrop animations for pagination jump dialog

* style: replace pagination ellipsis spans with buttons for improved accessibility

* fix: check if browser supports dialog API before continue with pagination jump setup

* fix: retain focus on last active element when closing pagination jump dialog

* fix: style for page-link button (pagination-jump)

* style: adjust width of pagination jump dialog, add container padding

* style: simplify

* refactor: simplify pagination link logic

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jimmy Cai <jimmy@cai.im>
2026-04-26 12:41:31 +02:00
JimmyandGitHub 8e3687431f fix: ensure taxonomy widget title is only linked if a taxonomy page exits (#1314)
fix: ensure taxonomy widget title is only linked if a taxonomy page exists
2026-03-21 12:01:29 +01:00
JimmyandGitHub cfcdb20d17 refactor: remove custom scrollbar styles and related variables (#1304) 2026-03-08 13:23:23 +01:00
0c941ebb04 fix: skip inaccessible images to avoid build failure (#1301)
* skip inaccessible images to avoid build failure

* undo changes to $result and $local

* fix: initialize $resource as a dictionary instead of an empty string

* feat: add external image processing timeout configuration

---------

Co-authored-by: Jimmy Cai <jimmy@cai.im>
2026-03-08 13:18:21 +01:00
77 changed files with 1244 additions and 205 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
main() {
DART_SASS_VERSION=1.97.1
GO_VERSION=1.25.5
HUGO_VERSION=0.157.0
HUGO_VERSION=0.160.1
NODE_VERSION=24.12.0
export TZ=Europe/Oslo
+2 -22
View File
@@ -1,6 +1,7 @@
html {
font-size: 62.5%;
overflow-y: scroll;
scrollbar-gutter: stable;
}
* {
@@ -17,25 +18,4 @@ body {
-ms-text-autospace: ideograph-alpha;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* scrollbar styles for Firefox */
* {
scrollbar-width: auto;
scrollbar-color: var(--scrollbar-thumb) transparent;
}
/**/
/* scrollbar styles for Chromium */
::-webkit-scrollbar {
height: auto;
}
::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
}
::-webkit-scrollbar-track {
background-color: transparent;
}
/**/
}
+80
View File
@@ -0,0 +1,80 @@
@import "../../breakpoints";
@import "../../mixins";
.artalk-container {
@include card-style;
padding: 5px;
@include respond(md) {
padding: var(--card-padding);
}
}
.artalk {
--at-color-bg: var(--card-background);
--at-color-font: var(--card-text-color-main);
--at-color-sub: var(--card-text-color-secondary);
--at-color-main: var(--accent-color);
--at-color-border: var(--card-separator-color);
--at-border-radius: var(--card-border-radius);
--at-color-link: var(--accent-color);
}
.artalk a {
color: var(--at-color-link);
text-decoration: none;
transition: color 0.3s ease;
&:hover {
color: var(--accent-color-darker);
}
}
.artalk .atk-content a,
.artalk .atk-editor-plug-preview a {
color: var(--accent-color);
box-shadow: 0px -2px 0px rgba(var(--link-background-color), var(--link-background-opacity)) inset;
transition: all 0.3s ease;
&:hover {
color: var(--accent-color-darker);
box-shadow: 0px calc(-1rem * var(--article-line-height)) 0px rgba(var(--link-background-color), var(--link-background-opacity-hover)) inset;
}
}
[data-scheme="dark"] .artalk {
--at-color-main: var(--accent-color);
--at-color-link: var(--accent-color);
--at-color-bg: var(--card-background);
--at-color-bg-transl: rgba(66, 66, 66, 0.95);
--at-color-bg-grey: rgba(255, 255, 255, 0.08);
--at-color-bg-grey-transl: rgba(255, 255, 255, 0.06);
--at-color-bg-light: rgba(255, 255, 255, 0.08);
--at-color-border: var(--card-separator-color);
--at-color-font: var(--card-text-color-main);
--at-color-deep: var(--card-text-color-main);
--at-color-sub: var(--card-text-color-secondary);
--at-color-meta: var(--card-text-color-secondary);
--at-color-grey: var(--card-text-color-secondary);
--at-color-gradient: linear-gradient(
180deg,
transparent,
var(--card-background)
);
}
.artalk code {
font-family: var(--code-font-family);
}
.artalk .atk-avatar img {
border-radius: var(--card-border-radius);
}
[data-scheme="dark"] .artalk .atk-avatar img {
filter: brightness(0.75);
}
[data-scheme="dark"] .artalk .atk-send-btn {
color: var(--accent-color-text);
}
+2
View File
@@ -5,6 +5,7 @@
right: 0;
z-index: 9999;
padding: var(--container-padding);
pointer-events: none;
&[aria-hidden="true"] {
display: none;
@@ -21,6 +22,7 @@
display: flex;
flex-direction: column;
gap: var(--spacing-md);
pointer-events: auto;
@include respond(md) {
flex-direction: row;
+7 -1
View File
@@ -52,12 +52,18 @@
/* LineNumbersTable */
.chroma .lnt {
margin-right: 0.4em;
margin-right: 0;
user-select: none;
padding: 0 0.4em 0 0.4em;
color: #7f7f7f;
display: block;
}
/* Optimise code block selection experience */
span.line {
margin-left: 0.4em;
}
/* LineNumbers */
.chroma .ln {
margin-right: 0.4em;
-4
View File
@@ -133,10 +133,6 @@
display: flex;
flex-direction: column;
color: var(--card-text-color-main);
::-webkit-scrollbar-thumb {
background-color: var(--card-separator-color);
}
}
.widget--toc {
+85 -6
View File
@@ -44,24 +44,72 @@
}
}
@property --left-edge-fade-size {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --right-edge-fade-size {
syntax: "<length>";
inherits: false;
initial-value: 64px;
}
.subsection-list {
--shadow-overlap-x: calc(var(--shadow-l2-blur) + 4px);
--shadow-overlap-y: calc(var(--shadow-l2-blur) + var(--shadow-l2-y) + 4px);
--edge-fade-size: 64px;
--left-edge-fade-size: 0px;
--right-edge-fade-size: var(--edge-fade-size);
overflow-x: auto;
/// Limit the horizontal overlap to the container padding to prevent page overflow on mobile.
/// On desktop, it still allows the full shadow to be visible.
--overlap-x: min(var(--shadow-overlap-x), var(--container-padding));
/// These values are computed from the shadow of the article-list--tile (which uses shadow-l1 and shadow-l2)
/// so that the shadow is not cut off
margin-left: calc(var(--shadow-overlap-x) * -1);
margin-right: calc(var(--shadow-overlap-x) * -1);
/// so that the shadow is not cut off, while staying within the viewport on small screens.
/// Clamp both sides to prevent page-level horizontal overflow.
margin-left: calc(var(--overlap-x) * -1);
margin-right: calc(var(--overlap-x) * -1);
margin-bottom: calc(var(--shadow-overlap-y) * -1);
/// Smooth fade on both horizontal edges to avoid hard cuts where shadows are clamped.
--subsection-mask-image: linear-gradient(
to right,
transparent 0%,
rgb(0 0 0 / 0.06) calc(var(--left-edge-fade-size) * 0.12),
rgb(0 0 0 / 0.2) calc(var(--left-edge-fade-size) * 0.3),
rgb(0 0 0 / 0.45) calc(var(--left-edge-fade-size) * 0.52),
rgb(0 0 0 / 0.72) calc(var(--left-edge-fade-size) * 0.76),
black var(--left-edge-fade-size),
black calc(100% - var(--right-edge-fade-size)),
rgb(0 0 0 / 0.72) calc(100% - (var(--right-edge-fade-size) * 0.76)),
rgb(0 0 0 / 0.45) calc(100% - (var(--right-edge-fade-size) * 0.52)),
rgb(0 0 0 / 0.2) calc(100% - (var(--right-edge-fade-size) * 0.3)),
rgb(0 0 0 / 0.06) calc(100% - (var(--right-edge-fade-size) * 0.12)),
transparent 100%
);
-webkit-mask-image: var(--subsection-mask-image);
mask-image: var(--subsection-mask-image);
[dir="rtl"] & {
margin-left: calc(var(--overlap-x) * -1);
margin-right: calc(var(--overlap-x) * -1);
}
.article-list--tile {
display: flex;
padding: var(--spacing-xs) var(--shadow-overlap-x) var(--shadow-overlap-y) var(--shadow-overlap-x);
padding: var(--spacing-xs) var(--overlap-x) var(--shadow-overlap-y) var(--overlap-x);
width: max-content;
gap: var(--spacing-lg);
[dir="rtl"] & {
padding: var(--spacing-xs) var(--overlap-x) var(--shadow-overlap-y) var(--overlap-x);
}
article {
flex-shrink: 0;
@@ -75,4 +123,35 @@
}
}
}
}
}
// Only apply scroll-driven edge fades in browsers that support scroll timelines.
// Browsers without support (e.g. Firefox) fall back to the static right-edge fade
// defined by the explicit CSS variable declarations above.
@supports (animation-timeline: scroll(self inline)) {
.subsection-list {
animation: subsection-left-fade linear both, subsection-right-fade linear both;
animation-timeline: scroll(self inline);
animation-range: 1px 24px, 0% 100%;
animation-timing-function: ease-out, linear;
}
}
@keyframes subsection-left-fade {
from {
--left-edge-fade-size: 0px;
}
to {
--left-edge-fade-size: var(--edge-fade-size);
}
}
@keyframes subsection-right-fade {
0%,
92% {
--right-edge-fade-size: var(--edge-fade-size);
}
100% {
--right-edge-fade-size: 0px;
}
}
+216 -5
View File
@@ -3,22 +3,44 @@
@include card-style;
overflow: hidden;
.hide-on-mobile,
.pagination-link {
display: none;
@include respond(md) {
display: flex;
}
}
.page-link {
padding: 16px 0;
padding: var(--spacing-sm) 0;
margin: 0;
border: 0;
background: none;
font: inherit;
border-radius: 0;
display: flex;
align-items: center;
justify-content: center;
flex: 1 1 auto;
min-width: 48px;
min-width: 36px;
color: var(--card-text-color-secondary);
text-decoration: none;
@include respond(md) {
min-width: 48px;
padding: var(--spacing-md) 0;
}
&:first-child,
&:last-child {
flex: 0 0 48px;
padding: 16px;
flex: 0 0 var(--spacing-3xl);
padding: var(--spacing-sm);
@include respond(md) {
flex: 0 0 48px;
padding: var(--spacing-md);
}
}
&:hover:not(.current):not(.disabled) {
@@ -44,5 +66,194 @@
width: 20px;
height: 20px;
}
&.pagination-jump-trigger {
cursor: pointer;
}
}
}
}
#pagination-jump-dialog {
border: none;
@include card-style;
box-shadow: var(--shadow-l2);
color: var(--body-text-color);
margin: auto; // Center dialog
width: calc(100% - (var(--container-padding) * 2));
max-width: 320px;
padding: var(--card-padding);
&::backdrop {
background-color: rgba(0, 0, 0, 0.4);
opacity: 0;
animation: backdrop-fade-in 0.3s ease forwards;
}
&[open] {
animation: scale-up 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}
&.closing {
animation: scale-down 0.2s ease forwards;
&::backdrop {
animation: backdrop-fade-out 0.2s ease forwards;
}
}
.pagination-jump-form {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-xs);
label {
cursor: pointer;
}
.title {
font-size: 1.6rem;
font-weight: 600;
color: var(--card-text-color-main);
margin: 0;
}
.subtitle {
font-size: 1.3rem;
color: var(--card-text-color-tertiary);
}
}
.pagination-jump-input-group {
display: flex;
gap: var(--spacing-sm);
align-items: stretch;
input[type="number"] {
flex: 1;
width: 100%;
min-width: 0;
padding: 0 var(--spacing-md);
height: 44px; // Explicit height for perfect alignment
border: 1.5px solid var(--card-separator-color);
border-radius: 6px;
background-color: var(--body-background);
color: var(--card-text-color-main);
font-size: 1.6rem;
outline: none;
-webkit-appearance: none;
appearance: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
/* Hide spinner */
-moz-appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
&:focus,
&:focus-visible {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color) 20%, transparent);
}
}
.btn-primary {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 var(--spacing-xl);
height: 44px; // Match input height
background-color: var(--accent-color);
color: var(--accent-color-text);
border: none;
border-radius: 6px;
font-size: 1.5rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
background-color: var(--accent-color-darker);
box-shadow: var(--shadow-l1);
}
&:active {
box-shadow: none;
}
}
}
.pagination-jump-hint {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--spacing-sm);
font-size: 1.3rem;
color: var(--card-text-color-tertiary);
margin-top: calc(var(--spacing-xs) * -1);
kbd {
background-color: var(--body-background);
border: 1px solid var(--card-separator-color);
border-radius: 4px;
padding: 3px 6px;
font-family: var(--base-font-family);
font-size: 1.1rem;
box-shadow: 0 2px 0 var(--card-separator-color);
color: var(--card-text-color-main);
font-weight: 600;
line-height: 1;
}
}
}
@keyframes scale-up {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes scale-down {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
@keyframes backdrop-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes backdrop-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
}
-5
View File
@@ -19,17 +19,12 @@
--section-separation: 40px;
--scrollbar-thumb: hsl(0, 0%, 85%);
--scrollbar-track: var(--body-background);
&[data-scheme="dark"] {
--body-background: #303030;
--accent-color: #ecf0f1;
--accent-color-darker: #bdc3c7;
--accent-color-text: #000;
--body-text-color: rgba(255, 255, 255, 0.7);
--scrollbar-thumb: hsl(0, 0%, 40%);
--scrollbar-track: var(--body-background);
}
}
+41
View File
@@ -0,0 +1,41 @@
import * as params from '@params';
export function setupCodeCopy() {
/**
* Add copy button to code block
*/
const highlights = document.querySelectorAll('.article-content div.highlight');
const copyText = params.codeblock.copy,
copiedText = params.codeblock.copied;
if (!navigator.clipboard) {
/// Clipboard API is only supported in secure contexts (HTTPS)
console.warn('Clipboard API not supported, copy button will not work.');
return;
}
highlights.forEach(highlight => {
const copyButton = document.createElement('button');
copyButton.innerHTML = copyText;
copyButton.classList.add('copyCodeButton');
highlight.appendChild(copyButton);
const codeBlock = highlight.querySelector('code[data-lang]');
if (!codeBlock) return;
copyButton.addEventListener('click', () => {
navigator.clipboard.writeText(codeBlock.textContent)
.then(() => {
copyButton.textContent = copiedText;
setTimeout(() => {
copyButton.textContent = copyText;
}, 1000);
})
.catch(err => {
alert(err)
console.log('Something went wrong', err);
});
});
});
};
+141
View File
@@ -0,0 +1,141 @@
/*
* Consent-gated comments bootstrap.
*
* This module controls whether third-party comments are rendered based on
* functional cookie consent, and injects template scripts in sequence to
* avoid eager or duplicate script execution.
*/
/**
* Consent event payload used by the comments gate.
* It mirrors cookie consent state shape but keeps optional fields for safety.
*/
interface CommentsConsentState {
necessary?: boolean;
analytics?: boolean;
functional?: boolean;
timestamp?: number;
}
/**
* Resolve functional consent with layered fallback:
* 1) explicit event detail, 2) CookieConsent public API, 3) dataset mirror.
*/
const hasFunctionalConsent = (consentDetail?: CommentsConsentState | null): boolean => {
const cookieConsent = (window as Window & {
cookieConsent?: {
hasConsent?: (category: 'necessary' | 'analytics' | 'functional') => boolean;
};
}).cookieConsent;
return (
consentDetail?.functional ??
cookieConsent?.hasConsent?.('functional') ??
document.documentElement.dataset.consentFunctional === 'true'
);
};
interface DeferredScript {
placeholder: Comment;
script: HTMLScriptElement;
}
const prepareDeferredScripts = (fragment: DocumentFragment): DeferredScript[] => {
return Array.from(fragment.querySelectorAll('script')).map(script => {
const placeholder = document.createComment('comments-script-placeholder');
script.replaceWith(placeholder);
return { placeholder, script };
});
};
const activateDeferredScripts = async (scripts: DeferredScript[]): Promise<void> => {
// Inject scripts sequentially so third-party embeds can rely on execution order.
for (const { placeholder, script } of scripts) {
if (!placeholder.parentNode) {
continue;
}
const newScript = document.createElement('script');
Array.from(script.attributes).forEach(attr => {
newScript.setAttribute(attr.name, attr.value);
});
if (script.textContent) {
newScript.text = script.textContent;
}
let done: Promise<void> = Promise.resolve();
if (newScript.src) {
done = new Promise<void>(resolve => {
newScript.onload = newScript.onerror = () => resolve();
});
}
placeholder.replaceWith(newScript);
await done;
}
};
const initCommentsConsent = (): void => {
// Main entry: gate comments rendering by functional cookie consent.
const placeholder = document.getElementById('comments-consent-placeholder') as HTMLElement | null;
const container = document.getElementById('comments-container') as HTMLElement | null;
const template = document.getElementById('comments-template') as HTMLTemplateElement | null;
if (!placeholder || !container || !template) {
return;
}
let commentsLoaded = false;
let commentsLoading = false;
const showComments = async (): Promise<void> => {
placeholder.style.display = 'none';
container.style.display = 'block';
if (commentsLoaded || commentsLoading) {
return;
}
// Prevent duplicate template hydration while scripts are still loading.
commentsLoading = true;
try {
const clone = template.content.cloneNode(true) as DocumentFragment;
const scripts = prepareDeferredScripts(clone);
container.appendChild(clone);
await activateDeferredScripts(scripts);
commentsLoaded = true;
} finally {
commentsLoading = false;
}
};
const hideComments = (): void => {
placeholder.style.display = 'block';
container.style.display = 'none';
};
window.addEventListener('onCookieConsentChange', (event: Event) => {
// Cross-module contract: this event is dispatched by cookies.ts.
const customEvent = event as CustomEvent<CommentsConsentState | null>;
if (hasFunctionalConsent(customEvent.detail)) {
showComments();
} else {
hideComments();
}
});
if (hasFunctionalConsent()) {
showComments();
} else {
hideComments();
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCommentsConsent, { once: true });
} else {
initCommentsConsent();
}
+1
View File
@@ -175,6 +175,7 @@ class CookieConsent {
}
private dispatchConsentEvent(): void {
// Cross-module event consumed by consent-gated features (for example commentsConsent.ts).
const event = new CustomEvent('onCookieConsentChange', {
detail: this.state
});
+4 -31
View File
@@ -10,6 +10,8 @@ import createElement from './createElement';
import StackColorScheme from './colorScheme';
import { setupScrollspy } from './scrollspy';
import { setupSmoothAnchors } from './smoothAnchors';
import { setupPaginationJump } from './pagination';
import { setupCodeCopy } from './code-copy';
let Stack = {
init: () => {
@@ -22,39 +24,10 @@ let Stack = {
if (articleContent) {
setupSmoothAnchors();
setupScrollspy();
setupCodeCopy();
}
/**
* Add copy button to code block
*/
const highlights = document.querySelectorAll('.article-content div.highlight');
const copyText = `Copy`,
copiedText = `Copied!`;
highlights.forEach(highlight => {
const copyButton = document.createElement('button');
copyButton.innerHTML = copyText;
copyButton.classList.add('copyCodeButton');
highlight.appendChild(copyButton);
const codeBlock = highlight.querySelector('code[data-lang]');
if (!codeBlock) return;
copyButton.addEventListener('click', () => {
navigator.clipboard.writeText(codeBlock.textContent)
.then(() => {
copyButton.textContent = copiedText;
setTimeout(() => {
copyButton.textContent = copyText;
}, 1000);
})
.catch(err => {
alert(err)
console.log('Something went wrong', err);
});
});
});
setupPaginationJump();
new StackColorScheme(document.getElementById('dark-mode-toggle')!);
}
+104
View File
@@ -0,0 +1,104 @@
export function setupPaginationJump() {
const triggers = document.querySelectorAll<HTMLButtonElement>('.pagination-jump-trigger');
const dialog = document.getElementById('pagination-jump-dialog') as HTMLDialogElement;
if (!dialog || triggers.length === 0) return;
const nav = document.querySelector('.pagination') as HTMLElement;
const input = document.getElementById('pagination-jump-input') as HTMLInputElement;
const form = dialog.querySelector('.pagination-jump-form') as HTMLFormElement;
const supportsDialog = typeof dialog.showModal === 'function' && typeof dialog.close === 'function';
let lastFocusedElement: HTMLElement | null = null;
if (!supportsDialog || !nav || !input || !form) return;
const closeDialog = (): void => {
if (dialog.classList.contains('closing')) return;
dialog.classList.add('closing');
dialog.addEventListener(
'animationend',
() => {
dialog.classList.remove('closing');
dialog.close();
if (lastFocusedElement?.isConnected) {
lastFocusedElement.focus();
}
},
{ once: true }
);
};
// Open dialog when triggers are clicked
triggers.forEach((trigger) => {
trigger.addEventListener('click', () => {
const activeElement = document.activeElement;
lastFocusedElement = activeElement instanceof HTMLElement ? activeElement : trigger;
dialog.showModal();
input.value = '';
input.focus();
});
});
// Handle ESC key closing the dialog
dialog.addEventListener('cancel', (e) => {
e.preventDefault();
closeDialog();
});
// Close dialog when clicking backdrop
dialog.addEventListener('click', (e) => {
const rect = dialog.getBoundingClientRect();
const isInDialog =
rect.top <= e.clientY &&
e.clientY <= rect.top + rect.height &&
rect.left <= e.clientX &&
e.clientX <= rect.left + rect.width;
if (!isInDialog) {
closeDialog();
}
});
// Allow Enter key to trigger validation and submit
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (form.reportValidity()) {
form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}
}
});
// Handle form submission
form.addEventListener('submit', (e) => {
e.preventDefault();
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const targetPage = parseInt(input.value);
if (isNaN(targetPage) || targetPage < 1) return;
const totalPages = parseInt(nav.dataset.total || '0');
if (targetPage > totalPages) return;
const firstUrl = nav.dataset.firstUrl || '';
const formatUrl = nav.dataset.formatUrl || '';
let targetUrl = '';
if (targetPage === 1) {
targetUrl = firstUrl;
} else {
// formatUrl is the URL for page 2. E.g., /tags/page/2/ or /page/2/
// Replace the '2' before the trailing slash or .html with the target page number
targetUrl = formatUrl.replace(/2([^\d]*)$/, `${targetPage}$1`);
}
if (targetUrl) {
window.location.href = targetUrl;
}
closeDialog();
});
}
+7
View File
@@ -77,6 +77,9 @@ SortBy = "default"
[imageProcessing]
autoOrient = false
[imageProcessing.external]
timeout = "5s"
[imageProcessing.content]
widths = [800, 1600, 2400]
enabled = true
@@ -98,6 +101,10 @@ SortBy = "default"
enabled = true
provider = "disqus"
[comments.artalk]
serverUrl = ""
site = ""
[comments.disqusjs]
shortname = ""
apiUrl = ""
+4
View File
@@ -13,3 +13,7 @@ Cactus = [
Style = "https://cdn.jsdelivr.net/npm/photoswipe@5.4.4/dist/photoswipe.css"
Core = "https://cdn.jsdelivr.net/npm/photoswipe@5.4.4/dist/photoswipe.esm.min.js"
Lightbox = "https://cdn.jsdelivr.net/npm/photoswipe@5.4.4/dist/photoswipe-lightbox.esm.min.js"
[Artalk]
Style = "https://cdn.jsdelivr.net/npm/artalk@2.9.1/dist/Artalk.css"
Script = "https://cdn.jsdelivr.net/npm/artalk@2.9.1/+esm"
+2 -2
View File
@@ -1,5 +1,5 @@
baseURL = "https://demo.stack.cai.im/"
languageCode = "en-us"
locale = "en-us"
title = "Hugo Theme Stack"
# Theme i18n support
# See i18n folder for available languages
@@ -12,7 +12,7 @@ hasCJKLanguage = false
path = "github.com/CaiJimmy/hugo-theme-stack/v3"
[pagination]
pagerSize = 5
pagerSize = 3
[permalinks]
post = "/p/:slug/"
+12 -12
View File
@@ -1,31 +1,31 @@
[en]
languageName = "English"
title = "Hugo Theme Stack"
weight = 1
label = "English"
title = "Hugo Theme Stack"
weight = 1
[en.params.sidebar]
subtitle = "Card-style Hugo theme designed for bloggers"
[zh]
languageName = "简体中文"
title = "Hugo 主题 Stack"
weight = 2
label = "简体中文"
title = "Hugo 主题 Stack"
weight = 2
[zh.params.sidebar]
subtitle = "为博客设计的卡片式 Hugo 主题"
[zh-hant-tw]
languageName = "正體中文"
title = "Hugo 主題 Stack"
weight = 3
label = "正體中文"
title = "Hugo 主題 Stack"
weight = 3
[zh-hant-tw.params.sidebar]
subtitle = "為博客設計的卡片式 Hugo 主題"
[ja]
languageName = "日本語"
title = "Hugo Theme Stack"
weight = 4
label = "日本語"
title = "Hugo Theme Stack"
weight = 4
[ja.params.sidebar]
subtitle = "ブログ用のカードスタイルの Hugo テーマ"
+14
View File
@@ -0,0 +1,14 @@
---
title: Pagination Test 01
date: 2000-01-01
description: Test post 01 for pagination behavior.
slug: pagination-test-01
tags:
- pagination
- test
categories:
- Testing
draft: false
---
This is pagination test post **01**.
+14
View File
@@ -0,0 +1,14 @@
---
title: Pagination Test 02
date: 2000-01-02
description: Test post 02 for pagination behavior.
slug: pagination-test-02
tags:
- pagination
- test
categories:
- Testing
draft: false
---
This is pagination test post **02**.
+14
View File
@@ -0,0 +1,14 @@
---
title: Pagination Test 03
date: 2000-01-03
description: Test post 03 for pagination behavior.
slug: pagination-test-03
tags:
- pagination
- test
categories:
- Testing
draft: false
---
This is pagination test post **03**.
+14
View File
@@ -0,0 +1,14 @@
---
title: Pagination Test 04
date: 2000-01-04
description: Test post 04 for pagination behavior.
slug: pagination-test-04
tags:
- pagination
- test
categories:
- Testing
draft: false
---
This is pagination test post **04**.
+14
View File
@@ -0,0 +1,14 @@
---
title: Pagination Test 05
date: 2000-01-05
description: Test post 05 for pagination behavior.
slug: pagination-test-05
tags:
- pagination
- test
categories:
- Testing
draft: false
---
This is pagination test post **05**.
+14
View File
@@ -0,0 +1,14 @@
---
title: Pagination Test 06
date: 2000-01-06
description: Test post 06 for pagination behavior.
slug: pagination-test-06
tags:
- pagination
- test
categories:
- Testing
draft: false
---
This is pagination test post **06**.
+14
View File
@@ -0,0 +1,14 @@
---
title: Pagination Test 07
date: 2000-01-07
description: Test post 07 for pagination behavior.
slug: pagination-test-07
tags:
- pagination
- test
categories:
- Testing
draft: false
---
This is pagination test post **07**.
+14
View File
@@ -0,0 +1,14 @@
---
title: Pagination Test 08
date: 2000-01-08
description: Test post 08 for pagination behavior.
slug: pagination-test-08
tags:
- pagination
- test
categories:
- Testing
draft: false
---
This is pagination test post **08**.
+14
View File
@@ -0,0 +1,14 @@
---
title: Pagination Test 09
date: 2000-01-09
description: Test post 09 for pagination behavior.
slug: pagination-test-09
tags:
- pagination
- test
categories:
- Testing
draft: false
---
This is pagination test post **09**.
+14
View File
@@ -0,0 +1,14 @@
---
title: Pagination Test 10
date: 2000-01-10
description: Test post 10 for pagination behavior.
slug: pagination-test-10
tags:
- pagination
- test
categories:
- Testing
draft: false
---
This is pagination test post **10**.
+14
View File
@@ -0,0 +1,14 @@
---
title: Pagination Test 11
date: 2000-01-11
description: Test post 11 for pagination behavior.
slug: pagination-test-11
tags:
- pagination
- test
categories:
- Testing
draft: false
---
This is pagination test post **11**.
+14
View File
@@ -0,0 +1,14 @@
---
title: Pagination Test 12
date: 2000-01-12
description: Test post 12 for pagination behavior.
slug: pagination-test-12
tags:
- pagination
- test
categories:
- Testing
draft: false
---
This is pagination test post **12**.
+4
View File
@@ -29,6 +29,10 @@ darkMode = "الوضع الداكن"
warning = "تحذير"
caution = "تنبيه"
[article.codeblock]
copy = "نسخ"
copied = "تم النسخ!"
[notFound]
title = "غير موجود"
subtitle = "تعذر العثور على الصفحة المطلوبة."
+4
View File
@@ -30,6 +30,10 @@ darkMode = "Цёмны рэжым"
warning = "Папярэджанне"
caution = "Асцярога"
[article.codeblock]
copy = "Капіяваць"
copied = "Скапіявана!"
[notFound]
title = "Не знойдзена"
subtitle = "Запытваемая старонка не існуе"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Тъмен Режим"
warning = "Предупреждение"
caution = "Внимание"
[article.codeblock]
copy = "Копирай"
copied = "Копирано!"
[notFound]
title = "Не е намерено"
subtitle = "Страницата която търсите не е открита"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "ডার্ক মোড"
warning = "সতর্কতা"
caution = "সাবধানতা"
[article.codeblock]
copy = "কপি"
copied = "কপি হয়েছে!"
[notFound]
title = "পাওয়া যায়নি"
subtitle = "এই পাতাটি বিদ্যমান নেই"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Mode fosc"
warning = "Avís"
caution = "Precaució"
[article.codeblock]
copy = "Copia"
copied = "Copiat!"
[notFound]
title = "No Trobat"
subtitle = "Aquesta pàgina no existeix"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Tmavý režim"
warning = "Varování"
caution = "Upozornění"
[article.codeblock]
copy = "Kopírovat"
copied = "Zkopírováno!"
[notFound]
title = "Nenalezeno"
subtitle = "Tato stránka neexistuje"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Dunkler Modus"
warning = "Warnung"
caution = "Vorsicht"
[article.codeblock]
copy = "Kopieren"
copied = "Kopiert!"
[notFound]
title = "Seite nicht gefunden"
subtitle = "Diese Seite existiert nicht"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Σκοτεινό θέμα"
warning = "Προειδοποίηση"
caution = "Προσοχή"
[article.codeblock]
copy = "Αντιγραφή"
copied = "Αντιγράφηκε!"
[notFound]
title = "Δε βρέθηκε"
subtitle = "Η σελίδα δε βρέθηκε."
+9
View File
@@ -29,6 +29,10 @@ darkMode = "Dark Mode"
warning = "Warning"
caution = "Caution"
[article.codeblock]
copy = "Copy"
copied = "Copied!"
[notFound]
title = "Not Found"
subtitle = "This page does not exist"
@@ -76,3 +80,8 @@ darkMode = "Dark Mode"
[cookies.functional]
title = "Functional"
text = "These cookies enable features like comments and embedded content."
[pagination]
jumpToPage = "Jump to page"
jump = "Go"
pressEnter = "Press Enter to jump"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Modo oscuro"
warning = "Advertencia"
caution = "Precaución"
[article.codeblock]
copy = "Copiar"
copied = "¡Copiado!"
[notFound]
title = "No Encontrado"
subtitle = "Esta página no existe"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "حالت شب"
warning = "هشدار"
caution = "احتیاط"
[article.codeblock]
copy = "کپی"
copied = "کپی شد!"
[notFound]
title = "یافت نشد"
subtitle = "این صحه وجود ندارد"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Mode sombre"
warning = "Avertissement"
caution = "Attention"
[article.codeblock]
copy = "Copier"
copied = "Copié !"
[notFound]
title = "Page non trouvée"
subtitle = "Cette page n'existe pas."
+4
View File
@@ -29,6 +29,10 @@ darkMode = "डार्क मोड"
warning = "चेतावनी"
caution = "सावधानी"
[article.codeblock]
copy = "कॉपी"
copied = "कॉपी हो गया!"
[notFound]
title = "404 नहीं मिला।"
subtitle = "यह पृष्ठ मौजूद नहीं है।"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Sötét Mód"
warning = "Figyelmeztetés"
caution = "Óvatosság"
[article.codeblock]
copy = "Másolás"
copied = "Másolva!"
[notFound]
title = "Nem található"
subtitle = "Ez az oldal nem létezik"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Mode Gelap"
warning = "Peringatan"
caution = "Perhatian"
[article.codeblock]
copy = "Salin"
copied = "Disalin!"
[notFound]
title = "Tidak ditemukan"
subtitle = "Halaman yang Anda akses tidak ditemukan."
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Modalità scura"
warning = "Avvertenza"
caution = "Attenzione"
[article.codeblock]
copy = "Copia"
copied = "Copiato!"
[notFound]
title = "Non trovato"
subtitle = "Questa pagina non esiste."
+9
View File
@@ -20,6 +20,10 @@ darkMode = "ダークモード"
warning = "警告"
caution = "注意"
[article.codeblock]
copy = "コピー"
copied = "コピーしました!"
[notFound]
title = "404 Not Found"
subtitle = "指定されたページは存在しません。"
@@ -67,3 +71,8 @@ darkMode = "ダークモード"
[cookies.functional]
title = "機能的"
text = "これらのCookieは、コメントや埋め込みコンテンツなどの機能を有効にします."
[pagination]
jumpToPage = "ページにジャンプ"
jump = "ジャンプ"
pressEnter = "Enter キーでジャンプ"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "다크 모드"
warning = "경고"
caution = "주의"
[article.codeblock]
copy = "복사"
copied = "복사됨!"
[notFound]
title = "찾을 수 없음"
subtitle = "페이지를 찾을 수 없습니다."
+4
View File
@@ -24,6 +24,10 @@ darkMode = "Donkere modus"
warning = "Waarschuwing"
caution = "Let op"
[article.codeblock]
copy = "Kopiëren"
copied = "Gekopieerd!"
[notFound]
title = "Niet gevonden"
subtitle = "Deze pagina bestaat niet."
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Mòde fosc"
warning = "Avertiment"
caution = "Atencion"
[article.codeblock]
copy = "Copiar"
copied = "Copiat!"
[notFound]
title = "Non trobat"
subtitle = "Aquesta pagina existís pas"
+4
View File
@@ -35,6 +35,10 @@ darkMode = "Tryb ciemny"
warning = "Ostrzeżenie"
caution = "Uwaga"
[article.codeblock]
copy = "Kopiuj"
copied = "Skopiowano!"
[notFound]
title = "Nie znaleziono"
subtitle = "Ta strona nie istnieje"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Modo Escuro"
warning = "Aviso"
caution = "Cuidado"
[article.codeblock]
copy = "Copiar"
copied = "Copiado!"
[notFound]
title = "Não Encontrado"
subtitle = "Esta página não existe."
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Modo Escuro"
warning = "Aviso"
caution = "Cuidado"
[article.codeblock]
copy = "Copiar"
copied = "Copiado!"
[notFound]
title = "Não Encontrado"
subtitle = "Esta página não existe."
+4
View File
@@ -30,6 +30,10 @@ darkMode = "Тёмный режим"
warning = "Предупреждение"
caution = "Осторожно"
[article.codeblock]
copy = "Копировать"
copied = "Скопировано!"
[notFound]
title = "Не найдено"
subtitle = "Запрашиваемая страница не существует"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Tmavý režim"
warning = "Varovanie"
caution = "Upozornenie"
[article.codeblock]
copy = "Kopírovať"
copied = "Skopírované!"
[notFound]
title = "Nenájdené"
subtitle = "Tato stránka neexistuje"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "ธีมมืด"
warning = "คำเตือน"
caution = "ระวัง"
[article.codeblock]
copy = "คัดลอก"
copied = "คัดลอกแล้ว!"
[notFound]
title = "ไม่พบหัวข้อ"
subtitle = "ไม่พบหน้านี้ในระบบ"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Koyu Mod"
warning = "Uyarı"
caution = "Dikkat"
[article.codeblock]
copy = "Kopyala"
copied = "Kopyalandı!"
[notFound]
title = "Bulunamadı"
subtitle = "Aradığınız sayfa mevcut değil."
+4
View File
@@ -30,6 +30,10 @@ darkMode = "Темна тема"
warning = "Попередження"
caution = "Обережно"
[article.codeblock]
copy = "Копіювати"
copied = "Скопійовано!"
[notFound]
title = "Не знайдено"
subtitle = "Ця сторінка не існує"
+4
View File
@@ -29,6 +29,10 @@ darkMode = "Chế độ nền tối"
warning = "Cảnh báo"
caution = "Thận trọng"
[article.codeblock]
copy = "Sao chép"
copied = "Đã sao chép!"
[notFound]
title = "Không tìm thấy"
subtitle = "Trang này không tồn tại"
+9
View File
@@ -20,6 +20,10 @@ darkMode = "深色模式"
warning = "警告"
caution = "注意"
[article.codeblock]
copy = "複製"
copied = "已複製!"
[notFound]
title = "找不到頁面"
subtitle = "本頁面並不存在"
@@ -67,3 +71,8 @@ darkMode = "深色模式"
[cookies.functional]
title = "功能性"
text = "這些 Cookie 啟用評論和嵌入內容等功能."
[pagination]
jumpToPage = "跳轉到頁碼"
jump = "跳轉"
pressEnter = "按 Enter 鍵跳轉"
+9
View File
@@ -20,6 +20,10 @@ darkMode = "夜晚模式"
warning = "警告"
caution = "注意"
[article.codeblock]
copy = "複製"
copied = "已複製!"
[notFound]
title = "404 錯誤"
subtitle = "頁面不存在"
@@ -67,3 +71,8 @@ darkMode = "夜晚模式"
[cookies.functional]
title = "功能性"
text = "這些 Cookie 啟用評論和嵌入內容等功能."
[pagination]
jumpToPage = "跳轉到頁碼"
jump = "跳轉"
pressEnter = "按 Enter 鍵跳轉"
+9
View File
@@ -20,6 +20,10 @@ darkMode = "暗色模式"
warning = "警告"
caution = "注意"
[article.codeblock]
copy = "复制"
copied = "已复制!"
[notFound]
title = "404 错误"
subtitle = "页面不存在"
@@ -67,3 +71,8 @@ darkMode = "暗色模式"
[cookies.functional]
title = "功能性"
text = "这些 Cookie 启用评论和嵌入内容等功能."
[pagination]
jumpToPage = "跳转到页码"
jump = "跳转"
pressEnter = "按 Enter 键跳转"
@@ -58,7 +58,7 @@
<div class="inline-meta">
{{ partial "helper/icon" "language" }}
{{ range $Page.Translations }}
<a href="{{ .RelPermalink }}" class="link">{{ .Language.LanguageName }}</a>
<a href="{{ .RelPermalink }}" class="link">{{ .Language.Label }}</a>
{{ end }}
</div>
{{ end }}
@@ -1,9 +1,9 @@
{{- $opts := dict "minify" hugo.IsProduction "format" "esm" -}}
{{- $galleryScript := resources.Get "ts/gallery.ts" | js.Build $opts -}}
{{ $style := .Site.Data.external.PhotoSwipe.Style }}
{{ $core := .Site.Data.external.PhotoSwipe.Core }}
{{ $lightbox := .Site.Data.external.PhotoSwipe.Lightbox }}
{{ $style := hugo.Data.external.PhotoSwipe.Style }}
{{ $core := hugo.Data.external.PhotoSwipe.Core }}
{{ $lightbox := hugo.Data.external.PhotoSwipe.Lightbox }}
<script type="module">
import gallery from '{{ $galleryScript.RelPermalink }}';
@@ -29,4 +29,4 @@
});
lightbox.init();
}
</script>
</script>
+13 -32
View File
@@ -1,3 +1,8 @@
{{/*
Comments include entry.
- If functional cookie consent is required, render a placeholder + template and load commentsConsent.ts.
- Otherwise, render provider partial directly.
*/}}
{{ if .Site.Params.comments.enabled }}
{{- $needsConsent := and .Site.Params.cookies.enabled .Site.Params.cookies.categories.functional -}}
{{- if $needsConsent -}}
@@ -8,40 +13,16 @@
{{ T "cookies.managePreferences" }}
</button>
</div>
<div id="comments-container" style="display: none;">
<div id="comments-container" style="display: none;"></div>
<template id="comments-template">
{{ partial (printf "comments/provider/%s" .Site.Params.comments.provider) . }}
</div>
<script>
(function() {
var placeholder = document.getElementById('comments-consent-placeholder');
var container = document.getElementById('comments-container');
function showComments() {
if (placeholder) placeholder.style.display = 'none';
if (container) container.style.display = 'block';
}
function hideComments() {
if (placeholder) placeholder.style.display = 'block';
if (container) container.style.display = 'none';
}
window.addEventListener('onCookieConsentChange', function(e) {
if (e.detail && e.detail.functional) {
showComments();
} else {
hideComments();
}
});
// Check if already consented
if (window.cookieConsent && window.cookieConsent.hasConsent('functional')) {
showComments();
}
})();
</script>
</template>
{{- $opts := dict "minify" hugo.IsProduction -}}
{{/* commentsConsent.ts is intentionally built and loaded here as an independent entry */}}
{{- $commentsScript := resources.Get "ts/commentsConsent.ts" | js.Build $opts | fingerprint -}}
<script type="text/javascript" src="{{ $commentsScript.RelPermalink }}" integrity="{{ $commentsScript.Data.Integrity }}" defer></script>
{{- else -}}
{{/* No consent required - load comments normally */}}
{{ partial (printf "comments/provider/%s" .Site.Params.comments.provider) . }}
{{- end -}}
{{ end }}
{{ end }}
@@ -0,0 +1,30 @@
{{- $style := resources.Get "scss/partials/comments/artalk.scss" | toCSS | minify -}}
{{- with .Site.Params.comments.artalk -}}
<link rel="stylesheet" href="{{ hugo.Data.external.Artalk.Style }}">
<link rel="stylesheet" href="{{ $style.RelPermalink }}">
<div id="Comments" class="artalk-container"></div>
<script type="module">
import Artalk from '{{ hugo.Data.external.Artalk.Script }}';
function isDarkMode() {
return document.documentElement.dataset.scheme === 'dark';
}
const artalkInstance = Artalk.init({
el: '#Comments',
pageKey: '{{ $.RelPermalink }}',
pageTitle: '{{ $.Title }}',
server: '{{ .serverUrl }}',
site: '{{ .site }}',
darkMode: isDarkMode(),
locale: 'auto',
});
window.addEventListener('onColorSchemeChange', (e) => {
if (!artalkInstance || typeof artalkInstance.setDarkMode !== 'function') return;
artalkInstance.setDarkMode(e.detail === 'dark');
});
</script>
{{- end -}}
@@ -1,4 +1,12 @@
{{- $opts := dict "minify" hugo.IsProduction -}}
{{- $opts := dict
"minify" hugo.IsProduction
"params" (dict
"codeblock" (dict
"copy" (i18n "article.codeblock.copy")
"copied" (i18n "article.codeblock.copied")
)
)
-}}
{{- $script := resources.Get "ts/main.ts" | js.Build $opts | fingerprint -}}
<script type="text/javascript" src="{{ $script.RelPermalink }}" defer></script>
@@ -7,4 +15,4 @@
{{/* Place your custom script in HUGO_SITE_FOLDER/assets/ts/custom.ts */}}
{{- $customScript := . | js.Build $opts | fingerprint -}}
<script type="text/javascript" src="{{ $customScript.RelPermalink }}" defer></script>
{{- end -}}
{{- end -}}
+1 -1
View File
@@ -1,4 +1,4 @@
{{- $ThemeVersion := "4.0.0" -}}
{{- $ThemeVersion := "4.0.2" -}}
<footer class="site-footer">
<section class="copyright">
&copy;
@@ -39,5 +39,5 @@
{{ $image := partial "helper/image" (dict "Image" .Params.image "Resources" .Resources "Context" .) }}
{{- if $image -}}
<meta property='og:image' content='{{ absURL $image.permalink }}' />
<meta property='og:image' content='{{ absURL $image.Permalink }}' />
{{- end -}}
@@ -12,5 +12,5 @@
{{- $image := partial "helper/image" (dict "Image" .Params.image "Resources" .Resources "Context" .) -}}
{{- if $image -}}
<meta name="twitter:card" content="{{ .Site.Params.opengraph.twitter.card }}">
<meta name="twitter:image" content='{{ absURL $image.permalink }}' />
<meta name="twitter:image" content='{{ absURL $image.Permalink }}' />
{{- end -}}
+2 -2
View File
@@ -1,4 +1,4 @@
{{- $List := index .Context.Site.Data.external .Namespace -}}
{{- $List := index hugo.Data.external .Namespace -}}
{{- with $List -}}
{{- range . -}}
{{- if eq .type "script" -}}
@@ -26,4 +26,4 @@
{{- end -}}
{{- else -}}
{{- errorf "Error: external resource '%s' is not found" .Namespace -}}
{{- end -}}
{{- end -}}
+11 -4
View File
@@ -14,7 +14,14 @@
{{ if .Image }}
{{ $url := urls.Parse .Image }}
{{ $permalink := .Image }}
{{ $resource := "" }}
{{ $result = dict
"Resource" nil
"Permalink" $permalink
"Height" nil
"Width" nil
}}
{{ $resource := dict }}
{{ $local := true }}
{{ if not (in (slice "https" "http") $url.Scheme) }}
@@ -22,14 +29,14 @@
{{ $resource = .Resources.Get (printf "%s" (.Image | safeURL)) }}
{{ else }}
{{/* Remote image */}}
{{ with try (resources.GetRemote $url) }}
{{ with try (resources.GetRemote $url (dict "timeout" .Context.Site.Params.imageProcessing.external.timeout)) }}
{{ with .Err }}
{{ errorf "%s" . }}
{{ warnf "Failed to fetch remote resource %q: %s" $url . }}
{{ else with .Value }}
{{ $resource = . }}
{{ $local = false }}
{{ else }}
{{ errorf "Unable to get remote resource %q" $url }}
{{ warnf "Unable to get remote resource %q" $url }}
{{ end }}
{{ end }}
{{ end }}
+133 -61
View File
@@ -1,70 +1,142 @@
{{- $pag := $.Paginator -}}
{{- if gt $pag.TotalPages 1 -}}
<nav class="pagination" role="navigation" aria-label="pagination">
{{- /* Previous page button */ -}}
<a class="page-link {{ if not $pag.Prev }}disabled{{ end }}"
{{- if $pag.Prev -}}href="{{ $pag.Prev.URL }}" {{- end -}}
aria-label="Previous page"
{{- if not $pag.Prev -}}aria-disabled="true"{{- end -}}>
{{ partial "helper/icon" "chevron-left" }}
</a>
{{- /* Page numbers logic */ -}}
{{- $window := 4 -}}
{{- $showFirst := true -}}
{{- $showLast := true -}}
{{- /* First page */ -}}
<a class="page-link {{ if eq $pag.PageNumber 1 }}current{{ end }}" aria-label="Page 1"
{{- if eq $pag.PageNumber 1 -}}aria-current="page" {{- end -}}
{{- if ne $pag.PageNumber 1 -}}href="{{ $pag.First.URL }}"{{- end -}}>
1
</a>
{{- /* Left ellipsis - show if current page is > 3 */ -}}
{{- if gt $pag.PageNumber 3 -}}
<span class="page-link pagination-link">
{{ partial "helper/icon" "dots" }}
</span>
{{- end -}}
{{- /* Middle pages - show current and neighbors */ -}}
{{- /* Find page 2's URL to extract the pagination base path for JavaScript */ -}}
{{- $page2Url := "" -}}
{{- range $pag.Pagers -}}
{{- if and (gt .PageNumber 1) (lt .PageNumber $pag.TotalPages) -}}
{{- if and (ge .PageNumber (sub $pag.PageNumber $window)) (le .PageNumber (add $pag.PageNumber $window)) -}}
<a class="page-link {{ if eq .PageNumber $pag.PageNumber }}current{{ end }}"
{{- if eq .PageNumber $pag.PageNumber -}}aria-current="page" {{- end -}} href="{{ .URL }}"
aria-label="Page {{ .PageNumber }}">
{{ .PageNumber }}
</a>
{{- end -}}
{{- if eq .PageNumber 2 -}}
{{- $page2Url = .URL -}}
{{- end -}}
{{- end -}}
{{- /* Right ellipsis - show if current page is < last-2 */ -}}
{{- if lt $pag.PageNumber (sub $pag.TotalPages 2) -}}
<span class="page-link pagination-link">
{{ partial "helper/icon" "dots" }}
</span>
{{- end -}}
<nav
class="pagination"
role="navigation"
aria-label="pagination"
data-total="{{ $pag.TotalPages }}"
data-first-url="{{ $pag.First.URL }}"
data-format-url="{{ $page2Url }}">
{{- /* Previous page button */ -}}
<a
class="page-link {{ if not $pag.Prev }}disabled{{ end }}"
{{- if $pag.Prev -}}
href="{{ $pag.Prev.URL }}"
aria-label="Previous page"
{{- end -}}
{{- if not $pag.Prev -}}aria-disabled="true"{{- end -}}>
{{ partial "helper/icon" "chevron-left" }}
</a>
{{- /* Last page (if more than 1 page total) */ -}}
{{- /* Page numbers logic */ -}}
{{- /* Compute visible middle window [start, end] (excluding first and last page). */ -}}
{{- /* Keep 3 middle pages when possible; if total <= 5, show all middle pages. */ -}}
{{- $curr := $pag.PageNumber -}}
{{- $total := $pag.TotalPages -}}
{{- $middleMinPage := 2 -}}
{{- $middleMaxPage := sub $total 1 -}}
{{- $start := $middleMinPage -}}
{{- $end := $middleMaxPage -}}
{{- if gt $pag.TotalPages 1 -}}
<a class="page-link {{ if eq $pag.PageNumber $pag.TotalPages }}current{{ end }}"
{{- if ne $pag.PageNumber $pag.TotalPages -}}href="{{ $pag.Last.URL }}" {{- end -}}
aria-label="Page {{ $pag.TotalPages }}"
{{- if eq $pag.PageNumber $pag.TotalPages -}}aria-current="page"{{- end -}}>
{{ $pag.TotalPages }}
</a>
{{- end -}}
{{- if gt $total 5 -}}
{{- $start = sub $curr 1 -}}
{{- if lt $start $middleMinPage -}}
{{- $start = $middleMinPage -}}
{{- end -}}
{{- $end = add $start 2 -}}
{{- if gt $end $middleMaxPage -}}
{{- $end = $middleMaxPage -}}
{{- $start = sub $end 2 -}}
{{- end -}}
{{- end -}}
{{- /* Next page button */ -}}
<a class="page-link {{ if not $pag.Next }}disabled{{ end }}"
{{- if $pag.Next -}}href="{{ $pag.Next.URL }}" {{- end -}}
aria-label="Next page"
{{- if not $pag.Next -}}aria-disabled="true"{{- end -}}>
{{ partial "helper/icon" "chevron-right" }}
</a>
</nav>
{{- end -}}
{{- /* First page */ -}}
<a
class="page-link {{ if eq $curr 1 }}current{{ end }}"
aria-label="Page 1"
{{- if eq $curr 1 -}}aria-current="page"{{- end -}}
{{- if ne $curr 1 -}}href="{{ $pag.First.URL }}"{{- end -}}>
1
</a>
{{- /* Left ellipsis - show if the window start is beyond page 2 */ -}}
{{- if gt $start $middleMinPage -}}
<button
type="button"
class="page-link pagination-link pagination-jump-trigger"
aria-label="{{ i18n "pagination.jumpToPage" | default "Jump to page" }}">
{{ partial "helper/icon" "dots" }}
</button>
{{- end -}}
{{- /* Middle pages - show pages in [start, end] */ -}}
{{- range $pag.Pagers -}}
{{- if and (ge .PageNumber $start) (le .PageNumber $end) -}}
{{- $hideOnMobile := and (ne .PageNumber $curr) (ne .PageNumber (sub $curr 1)) (ne .PageNumber (add $curr 1)) -}}
<a
class="page-link {{ if eq .PageNumber $curr }}current{{ end }} {{ if $hideOnMobile }}hide-on-mobile{{ end }}"
{{- if eq .PageNumber $curr -}}aria-current="page"{{- end -}}
href="{{ .URL }}"
aria-label="Page {{ .PageNumber }}">
{{ .PageNumber }}
</a>
{{- end -}}
{{- end -}}
{{- /* Right ellipsis - show if the window end is before the second-to-last page */ -}}
{{- if lt $end $middleMaxPage -}}
<button
type="button"
class="page-link pagination-link pagination-jump-trigger"
aria-label="{{ i18n "pagination.jumpToPage" | default "Jump to page" }}">
{{ partial "helper/icon" "dots" }}
</button>
{{- end -}}
{{- /* Last page */ -}}
<a
class="page-link {{ if eq $curr $total }}current{{ end }}"
{{- if ne $curr $total -}}href="{{ $pag.Last.URL }}"{{- end -}}
aria-label="Page {{ $total }}"
{{- if eq $curr $total -}}aria-current="page"{{- end -}}>
{{ $total }}
</a>
{{- /* Next page button */ -}}
<a
class="page-link {{ if not $pag.Next }}disabled{{ end }}"
{{- if $pag.Next -}}
href="{{ $pag.Next.URL }}"
aria-label="Next page"
{{- end -}}
{{- if not $pag.Next -}}aria-disabled="true"{{- end -}}>
{{ partial "helper/icon" "chevron-right" }}
</a>
</nav>
<dialog id="pagination-jump-dialog" aria-labelledby="pagination-jump-title">
<form method="dialog" class="pagination-jump-form">
<header>
<label id="pagination-jump-title" class="title" for="pagination-jump-input">
{{ i18n "pagination.jumpToPage" | default "Jump to page:" }}
</label>
<span class="subtitle">(1 - {{ $pag.TotalPages }})</span>
</header>
<div class="pagination-jump-input-group">
<input
type="number"
id="pagination-jump-input"
min="1"
max="{{ $pag.TotalPages }}"
autofocus
autocomplete="off"
required>
<button type="submit" class="btn btn-primary">
{{ i18n "pagination.jump" | default "Go" }}
</button>
</div>
<div class="pagination-jump-hint">
<kbd>Enter</kbd>
<span>{{ i18n "pagination.pressEnter" | default "Press Enter to jump" }}</span>
</div>
</form>
</dialog>
{{- end -}}
+3 -3
View File
@@ -64,7 +64,7 @@
{{ end }}
<li class="menu-bottom-section">
<ol class="menu">
{{- $currentLanguageCode := .Language.Lang -}}
{{- $currentLanguageCode := .Language.Name -}}
{{ if hugo.IsMultilingual }}
<li id="i18n-switch">
{{ partial "helper/icon" "language" }}
@@ -72,11 +72,11 @@
{{ range .Site.Home.AllTranslations }}
{{ $target := . }}
{{ range $.AllTranslations }}
{{ if eq .Language.Lang $target.Language.Lang }}
{{ if eq .Language.Name $target.Language.Name }}
{{ $target = . }}
{{ end }}
{{ end }}
<option value="{{ $target.RelPermalink }}" {{ if eq $target.Language.Lang $currentLanguageCode }}selected{{ end }}>{{ .Language.LanguageName }}</option>
<option value="{{ $target.RelPermalink }}" {{ if eq $target.Language.Name $currentLanguageCode }}selected{{ end }}>{{ .Language.Label }}</option>
{{ end }}
</select>
</li>
+1 -1
View File
@@ -20,7 +20,7 @@
{{ partial "helper/icon" $icon }}
</div>
<h2 class="widget-title section-title">
{{ if $showLink }}
{{ if and $showLink $taxonomyPage }}
<a href="{{ $taxonomyPage.RelPermalink }}">
{{ $widgetTitle }}
</a>
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode }}" dir="{{ default `ltr` .Language.LanguageDirection }}">
<html lang="{{ .Site.Language.Locale }}" dir="{{ default `ltr` .Language.Direction }}">
<head>
{{- partial "head/head.html" . -}}
{{- block "head" . -}}{{ end }}
+2 -2
View File
@@ -30,7 +30,7 @@
<link>{{ .Permalink }}</link>
<description>Recent content {{ if ne .Title .Site.Title }}{{ with .Title }}in {{ . }} {{ end }}{{ end }}on {{ .Site.Title }}</description>
<generator>Hugo -- gohugo.io</generator>
<language>{{ site.Language.LanguageCode }}</language>{{ with $authorEmail }}
<language>{{ site.Language.Locale }}</language>{{ with $authorEmail }}
<managingEditor>{{.}}{{ with $authorName }} ({{ . }}){{ end }}</managingEditor>{{ end }}{{ with $authorEmail }}
<webMaster>{{ . }}{{ with $authorName }} ({{ . }}){{ end }}</webMaster>{{ end }}{{ with .Site.Copyright }}
<copyright>{{ . }}</copyright>{{ end }}{{ if not .Date.IsZero }}
@@ -52,7 +52,7 @@
{{- end -}}
{{- $image := partial "helper/image" (dict "Image" .Params.image "Resources" .Resources "Context" .) -}}
{{- if $image -}}
{{- $imgTag := printf "<img src=\"%s\" alt=\"Featured image of post %s\" />" ($image.permalink | absURL) .Title -}}
{{- $imgTag := printf "<img src=\"%s\" alt=\"Featured image of post %s\" />" ($image.Permalink | absURL) .Title -}}
{{- $content = printf "%s%s" $imgTag $content -}}
{{- end -}}
{{- $content | transform.XMLEscape | safeHTML -}}