Compare commits

...
10 Commits
Author SHA1 Message Date
Jimmy Cai 3e123a30b7 release: 4.0.3 2026-05-25 10:44:02 +02:00
JimmyandGitHub e409eddf5c fix: update TOC manual disable check to read default site parameter (#1341)
* fix: update TOC manual disable check to read default site parameter

* Article -> article

* Undo formatting

* fix: simplify TOC enabled logic by removing manual disable check
2026-05-25 10:43:36 +02:00
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
18 changed files with 312 additions and 63 deletions
+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;
+12 -7
View File
@@ -95,13 +95,6 @@
-webkit-mask-image: var(--subsection-mask-image);
mask-image: var(--subsection-mask-image);
// CSS-only: left fade ramps in quickly once horizontal scrolling starts,
// and right fade turns off near the end of horizontal scroll.
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;
[dir="rtl"] & {
margin-left: calc(var(--overlap-x) * -1);
margin-right: calc(var(--overlap-x) * -1);
@@ -132,6 +125,18 @@
}
}
// 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;
+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
View File
@@ -101,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"
+1 -1
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
+4 -4
View File
@@ -1,5 +1,5 @@
[en]
languageName = "English"
label = "English"
title = "Hugo Theme Stack"
weight = 1
@@ -7,7 +7,7 @@
subtitle = "Card-style Hugo theme designed for bloggers"
[zh]
languageName = "简体中文"
label = "简体中文"
title = "Hugo 主题 Stack"
weight = 2
@@ -15,7 +15,7 @@
subtitle = "为博客设计的卡片式 Hugo 主题"
[zh-hant-tw]
languageName = "正體中文"
label = "正體中文"
title = "Hugo 主題 Stack"
weight = 3
@@ -23,7 +23,7 @@
subtitle = "為博客設計的卡片式 Hugo 主題"
[ja]
languageName = "日本語"
label = "日本語"
title = "Hugo Theme Stack"
weight = 4
@@ -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 }}
+12 -31
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,38 +13,14 @@
{{ 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) . }}
@@ -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 -1
View File
@@ -1,4 +1,4 @@
{{- $ThemeVersion := "4.0.0" -}}
{{- $ThemeVersion := "4.0.3" -}}
<footer class="site-footer">
<section class="copyright">
&copy;
+3 -1
View File
@@ -14,12 +14,14 @@
{{ if .Image }}
{{ $url := urls.Parse .Image }}
{{ $permalink := .Image }}
{{ $resource := dict
{{ $result = dict
"Resource" nil
"Permalink" $permalink
"Height" nil
"Width" nil
}}
{{ $resource := dict }}
{{ $local := true }}
{{ if not (in (slice "https" "http") $url.Scheme) }}
+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
@@ -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 }}
+1 -1
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 }}
+1 -2
View File
@@ -15,8 +15,7 @@
{{- end -}}
{{- end -}}
{{- $TOCManuallyDisabled := eq .Params.toc false -}}
{{- $TOCEnabled := and (not $TOCManuallyDisabled) $TOCWidgetEnabled -}}
{{- $TOCEnabled := and (default .Site.Params.article.toc .Params.toc) $TOCWidgetEnabled -}}
{{- $hasTOC := ge (len .TableOfContents) 100 -}}
{{- .Scratch.Set "TOCEnabled" (and $TOCEnabled $hasTOC) -}}