diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index a222d4e..95f0c18 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ -ko_fi: jimmycai \ No newline at end of file +ko_fi: jimmycai +github: CaiJimmy diff --git a/.gitignore b/.gitignore index 9ebefdf..9ff142d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ public resources -assets/jsconfig.json \ No newline at end of file +assets/jsconfig.json +.hugo_build.lock \ No newline at end of file diff --git a/README.md b/README.md index 0e6caa2..1c59619 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ > Card-style Hugo theme designed for bloggers. +## Quickstart + +Use this template: [CaiJimmy/hugo-theme-stack-starter](https://github.com/CaiJimmy/hugo-theme-stack-starter) + ## Demo [Example Site](https://demo.stack.jimmycai.com/) @@ -28,6 +32,7 @@ Stack is a simple card-style Hugo theme designed for bloggers, some of its featu - Properly cropped thumbnails - Subsection support - Table of contents +- Multilingual mode and RTL support ## Requirements diff --git a/assets/icons/categories.svg b/assets/icons/categories.svg new file mode 100644 index 0000000..e00ab1d --- /dev/null +++ b/assets/icons/categories.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/language.svg b/assets/icons/language.svg new file mode 100644 index 0000000..66ede1c --- /dev/null +++ b/assets/icons/language.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/scss/grid.scss b/assets/scss/grid.scss index a0eddca..84967ef 100644 --- a/assets/scss/grid.scss +++ b/assets/scss/grid.scss @@ -11,7 +11,7 @@ /// Display right sidebar when min-width: lg @include respond(lg) { - display: block; + display: flex; } } @@ -67,24 +67,6 @@ } } - &.align-items--flex-start { - align-items: flex-start; - } - - .grow { - flex-grow: 1; - } - - .do-not-shrink { - flex-shrink: 0; - } - - .do-not-overflow { - min-width: 0; - flex-shrink: 1; - max-width: 100%; - } - .full-width { width: 100%; } @@ -94,15 +76,22 @@ main.main { min-width: 0; max-width: 100%; flex-grow: 1; - padding-top: var(--main-top-padding); + display: flex; + flex-direction: column; + gap: var(--section-separation); + + @include respond(md) { + padding-top: var(--main-top-padding); + } } .main-container { min-height: 100vh; align-items: flex-start; padding: 0 15px; - column-gap: var(--section-separation); - + gap: var(--section-separation); + padding-top: var(--main-top-padding); + @include respond(md) { padding: 0 20px; } diff --git a/assets/scss/partials/article.scss b/assets/scss/partials/article.scss index 459c22f..f085ff0 100644 --- a/assets/scss/partials/article.scss +++ b/assets/scss/partials/article.scss @@ -2,6 +2,7 @@ .article-list { display: flex; flex-direction: column; + gap: var(--section-separation); article { display: flex; @@ -17,10 +18,6 @@ box-shadow: var(--shadow-l2); } - &:not(:last-of-type) { - margin-bottom: var(--section-separation); - } - .article-image { img { width: 100%; @@ -52,13 +49,13 @@ display: flex; flex-direction: column; justify-content: center; - padding: var(--card-padding); + gap: 15px; } .article-title { font-weight: 600; - margin: 10px 0; + margin: 0; color: var(--card-text-color-main); font-size: 2.2rem; @@ -73,52 +70,58 @@ color: var(--card-text-color-main); } } - - & + .article-subtitle { - margin-top: 0; - } } .article-subtitle { font-weight: normal; color: var(--card-text-color-secondary); - margin: 5px 0; line-height: 1.5; - + margin: 0; font-size: 1.75rem; @include respond(xl) { font-size: 2rem; } } -.article-time { +.article-title-wrapper { + display: flex; + flex-direction: column; + gap: 8px; +} + +.article-time, +.article-translations { display: flex; align-items: center; color: var(--card-text-color-tertiary); gap: 15px; - margin-top: 10px; flex-wrap: wrap; svg { vertical-align: middle; - margin-right: 15px; width: 20px; height: 20px; stroke-width: 1.33; } - time { + time, + a { font-size: 1.4rem; + color: var(--card-text-color-tertiary); } & > div { display: inline-flex; align-items: center; + gap: 15px; } } .article-category, .article-tags { + display: flex; + gap: 10px; + a { color: var(--accent-color-text); background-color: var(--accent-color); @@ -126,8 +129,6 @@ border-radius: var(--tag-border-radius); display: inline-block; font-size: 1.4rem; - margin-right: 10px; - margin-bottom: 10px; transition: background-color 0.5s ease; &:hover { @@ -148,15 +149,12 @@ --image-size: 60px; } - & + .pagination { - margin-top: var(--section-separation); - } - article { & > a { display: flex; align-items: center; padding: var(--small-card-padding); + gap: 15px; } &:not(:last-of-type) { @@ -166,8 +164,8 @@ .article-details { flex-grow: 1; padding: 0; - padding-right: 15px; min-height: var(--image-size); + gap: 10px; } .article-title { diff --git a/assets/scss/partials/footer.scss b/assets/scss/partials/footer.scss index 1c60dc7..ccb7394 100644 --- a/assets/scss/partials/footer.scss +++ b/assets/scss/partials/footer.scss @@ -2,7 +2,6 @@ footer.site-footer { padding: 20px 0 var(--section-separation) 0; font-size: 1.4rem; line-height: 1.75; - margin-top: var(--section-separation); &:before { content: ""; diff --git a/assets/scss/partials/layout/archives.scss b/assets/scss/partials/layout/archives.scss deleted file mode 100644 index 34e5f62..0000000 --- a/assets/scss/partials/layout/archives.scss +++ /dev/null @@ -1,3 +0,0 @@ -.archives-group { - margin-bottom: var(--section-separation); -} \ No newline at end of file diff --git a/assets/scss/partials/layout/list.scss b/assets/scss/partials/layout/list.scss index 1a0e346..d7815ca 100644 --- a/assets/scss/partials/layout/list.scss +++ b/assets/scss/partials/layout/list.scss @@ -3,21 +3,16 @@ background-color: var(--card-background); padding: var(--small-card-padding); box-shadow: var(--shadow-l1); - margin-bottom: var(--section-separation); display: flex; align-items: center; + gap: 20px; --separation: 15px; .section-term { font-size: 2.2rem; margin: 0; - margin-top: calc(var(--separation) / 2); color: var(--card-text-color-main); - - & + .section-description { - margin-top: var(--separation); - } } .section-description { @@ -29,7 +24,9 @@ .section-details { flex-grow: 1; - margin-right: 20px; + display: flex; + flex-direction: column; + gap: 8px; } .section-image { @@ -49,7 +46,6 @@ } .subsection-list { - margin-bottom: var(--section-separation); overflow-x: auto; .article-list--tile { diff --git a/assets/scss/partials/layout/search.scss b/assets/scss/partials/layout/search.scss index b390a7b..89cdcef 100644 --- a/assets/scss/partials/layout/search.scss +++ b/assets/scss/partials/layout/search.scss @@ -1,5 +1,4 @@ .search-form { - margin-bottom: var(--section-separation); position: relative; --button-size: 80px; @@ -25,7 +24,7 @@ label { position: absolute; top: 15px; - left: 20px; + inset-inline-start: 20px; font-size: 1.4rem; color: var(--card-text-color-tertiary); } @@ -52,7 +51,7 @@ button { position: absolute; - right: 0; + inset-inline-end: 0; top: 0; height: 100%; width: var(--button-size); @@ -79,4 +78,5 @@ height: 20px; } } + } \ No newline at end of file diff --git a/assets/scss/partials/menu.scss b/assets/scss/partials/menu.scss index 1d612d1..4f29c9f 100644 --- a/assets/scss/partials/menu.scss +++ b/assets/scss/partials/menu.scss @@ -101,11 +101,16 @@ background: none; border: none; position: absolute; - right: 30px; - top: 30px; + right: 0; + top: 0; z-index: 2; cursor: pointer; + [dir="rtl"] & { + left: 0; + right: auto; + } + @include respond(md) { display: none; } @@ -125,21 +130,31 @@ .menu { padding-left: 0; list-style: none; - display: flex; flex-direction: column; overflow-y: auto; flex-grow: 1; font-size: 1.4rem; - background-color: var(--card-background); - padding: 15px 0; + box-shadow: var(--shadow-l1); display: none; + margin: 0 calc(var(--container-padding) * -1); - margin: 0 -15px; + padding: 30px 30px; + @include respond(xl) { + padding: 15px 0; + } + + &, + .menu-bottom-section { + gap: 30px; + @include respond(xl) { + gap: 25px; + } + } &.show { - display: block; + display: flex; } @include respond(md) { @@ -149,34 +164,19 @@ padding: 0; box-shadow: none; margin: 0; - margin-top: var(--sidebar-element-separation); - } - - @include respond(xl) { - margin-top: 30px; } li { position: relative; vertical-align: middle; - padding: 10px 30px; - - &:not(:last-of-type) { - margin-bottom: 15px; - - @include respond(xl) { - margin-bottom: 20px; - } - } + padding: 0; @include respond(md) { width: 100%; - padding: 10px 0; } svg { stroke-width: 1.33; - margin-right: 40px; width: 20px; height: 20px; @@ -187,6 +187,7 @@ display: inline-flex; align-items: center; color: var(--body-text-color); + gap: var(--menu-icon-separation); } span { @@ -200,11 +201,19 @@ } } } + + .menu-bottom-section { + margin-top: auto; + display: flex; + flex-direction: column; + width: 100%; + } } .social-menu { list-style: none; - padding: 0%; + padding: 0; + margin: 0; display: flex; flex-direction: row; gap: 10px; diff --git a/assets/scss/partials/pagination.scss b/assets/scss/partials/pagination.scss index a6c6882..ca46780 100644 --- a/assets/scss/partials/pagination.scss +++ b/assets/scss/partials/pagination.scss @@ -5,7 +5,6 @@ border-radius: var(--card-border-radius); overflow: hidden; flex-wrap: wrap; - margin: var(--section-separation) 0; .page-link { padding: 16px 32px; diff --git a/assets/scss/partials/sidebar.scss b/assets/scss/partials/sidebar.scss index 462729d..95310ca 100644 --- a/assets/scss/partials/sidebar.scss +++ b/assets/scss/partials/sidebar.scss @@ -11,13 +11,15 @@ flex-direction: column; flex-shrink: 0; align-self: stretch; - - width: 100%; - padding: 30px 0 15px 0; + gap: var(--sidebar-element-separation); max-width: none; + width: 100%; + position: relative; - --sidebar-avatar-size: 120px; + --sidebar-avatar-size: 100px; --sidebar-element-separation: 20px; + --emoji-size: 40px; + --emoji-font-size: 20px; @include respond(md) { width: auto; @@ -27,18 +29,49 @@ } @include respond(2xl) { - --sidebar-avatar-size: 140px; + --sidebar-avatar-size: 120px; --sidebar-element-separation: 25px; + --emoji-size: 40px; } &.sticky { top: 0; } + + &.compact { + --sidebar-avatar-size: 80px; + --emoji-size: 30px; + --emoji-font-size: 15px; + + header { + @include respond(lg) { + flex-direction: row; + } + + .site-meta { + gap: 5px; + } + + .site-name { + font-size: 1.4rem; + + @include respond(2xl) { + font-size: 1.75rem; + } + } + + .site-description { + font-size: 1.4rem; + } + } + } } .right-sidebar { - flex-shrink: 0; + width: 100%; display: none; + flex-direction: column; + gap: var(--widget-separation); &.sticky { top: 0; @@ -49,11 +82,12 @@ } } -.site-info { +.sidebar header { z-index: 1; transition: box-shadow 0.5s ease; - - padding: 15px; + display: flex; + flex-direction: column; + gap: var(--sidebar-element-separation); @include respond(md) { padding: 0; @@ -64,8 +98,7 @@ margin: 0; width: var(--sidebar-avatar-size); height: var(--sidebar-avatar-size); - - margin-bottom: var(--sidebar-element-separation); + flex-shrink: 0; .site-logo { width: 100%; @@ -76,58 +109,44 @@ .emoji { position: absolute; - width: 40px; - height: 40px; - line-height: 40px; + width: var(--emoji-size); + height: var(--emoji-size); + line-height: var(--emoji-size); border-radius: 100%; bottom: 0; right: 0; text-align: center; - font-size: 20px; + font-size: var(--emoji-font-size); background-color: var(--card-background); box-shadow: var(--shadow-l2); - - @include respond(2xl) { - width: 50px; - height: 50px; - line-height: 50px; - } } } + .site-meta { + display: flex; + flex-direction: column; + gap: 10px; + justify-content: center; + } + .site-name { color: var(--accent-color); margin: 0; - font-size: 1.8rem; - - @include respond(2xl) { - font-size: 2rem; - } - } - - .site-description { - color: var(--body-text-color); - font-weight: normal; - margin: 10px 0; font-size: 1.6rem; @include respond(2xl) { font-size: 1.8rem; } } -} -.sidebar { - .widget { - margin-bottom: var(--section-separation); + .site-description { + color: var(--body-text-color); + font-weight: normal; + margin: 0; + font-size: 1.4rem; - &:not(:last-of-type):after { - content: ""; - width: 100px; - height: 2px; - background-color: var(--body-text-color); - display: block; - margin-top: var(--section-separation); + @include respond(2xl) { + font-size: 1.6rem; } } } @@ -153,8 +172,27 @@ display: flex; align-items: center; cursor: pointer; + gap: var(--menu-icon-separation); .icon-tabler-toggle-right { display: none; } } + +#i18n-switch { + color: var(--body-text-color); + display: inline-flex; + align-content: center; + gap: var(--menu-icon-separation); + + select { + border: 0; + background-color: transparent; + color: var(--body-text-color); + + option { + color: var(--card-text-color-main); + background-color: var(--card-background); + } + } +} diff --git a/assets/scss/partials/widgets.scss b/assets/scss/partials/widgets.scss index 33a02dd..42cfcc2 100644 --- a/assets/scss/partials/widgets.scss +++ b/assets/scss/partials/widgets.scss @@ -1,4 +1,7 @@ .widget { + display: flex; + flex-direction: column; + .widget-icon { svg { width: 32px; @@ -14,16 +17,14 @@ .tagCloud-tags { display: flex; flex-wrap: wrap; + gap: 10px; a { background: var(--card-background); box-shadow: var(--shadow-l1); border-radius: var(--tag-border-radius); padding: 8px 20px; - color: var(--card-text-color-main); - margin-bottom: 10px; - margin-right: 5px; font-size: 1.4rem; transition: box-shadow 0.3s ease; diff --git a/assets/scss/style.scss b/assets/scss/style.scss index 2f5aac9..43e95a5 100644 --- a/assets/scss/style.scss +++ b/assets/scss/style.scss @@ -19,7 +19,6 @@ @import "partials/pagination.scss"; @import "partials/sidebar.scss"; @import "partials/base.scss"; -@import "partials/layout/archives.scss"; @import "partials/layout/article.scss"; @import "partials/layout/list.scss"; @import "partials/layout/404.scss"; diff --git a/assets/ts/gallery.ts b/assets/ts/gallery.ts index 5de13a2..9840f1e 100644 --- a/assets/ts/gallery.ts +++ b/assets/ts/gallery.ts @@ -57,6 +57,60 @@ class StackGallery { } public static createGallery(container: HTMLElement) { + /// The process of wrapping image with figure tag is done using JavaScript instead of only Hugo markdown render hook + /// because it can not detect whether image is being wrapped by a link or not + /// and it lead to a invalid HTML construction (
) + + const images = container.querySelectorAll('img.gallery-image'); + for (const img of Array.from(images)) { + /// Images are wrapped with figure tag if the paragraph has only images without texts + /// This is done to allow inline images within paragraphs + const paragraph = img.closest('p'); + + if (!paragraph || !container.contains(paragraph)) continue; + + if (paragraph.textContent.trim() == '') { + /// Once we insert figcaption, this check no longer works + /// So we add a class to paragraph to mark it + paragraph.classList.add('no-text'); + } + + let isNewLineImage = paragraph.classList.contains('no-text'); + if (!isNewLineImage) continue; + + const hasLink = img.parentElement.tagName == 'A'; + + let el: HTMLElement = img; + /// Wrap image with figure tag, with flex-grow and flex-basis values extracted from img's data attributes + const figure = document.createElement('figure'); + figure.style.setProperty('flex-grow', img.getAttribute('data-flex-grow') || '1'); + figure.style.setProperty('flex-basis', img.getAttribute('data-flex-basis') || '0'); + if (hasLink) { + /// Wrap if it exists + el = img.parentElement; + } + el.parentElement.insertBefore(figure, el); + figure.appendChild(el); + + /// Add figcaption if it exists + if (img.hasAttribute('alt')) { + const figcaption = document.createElement('figcaption'); + figcaption.innerText = img.getAttribute('alt'); + figure.appendChild(figcaption); + } + + /// Wrap img tag with tag if image was not wrapped by tag + if (!hasLink) { + figure.className = 'gallery-image'; + + const a = document.createElement('a'); + a.href = img.src; + a.setAttribute('target', '_blank'); + img.parentNode.insertBefore(a, img); + a.appendChild(img); + } + } + const figuresEl = container.querySelectorAll('figure.gallery-image'); let currentGallery = []; diff --git a/assets/ts/scrollspy.ts b/assets/ts/scrollspy.ts new file mode 100644 index 0000000..8a14085 --- /dev/null +++ b/assets/ts/scrollspy.ts @@ -0,0 +1,131 @@ +// Implements a scroll spy system for the ToC, displaying the current section with an indicator and scrolling to it when needed. + +// Inspired from https://gomakethings.com/debouncing-your-javascript-events/ +function debounced(func: Function) { + let timeout; + return () => { + if (timeout) { + window.cancelAnimationFrame(timeout); + } + + timeout = window.requestAnimationFrame(() => func()); + } +} + +const headersQuery = ".article-content h1[id], .article-content h2[id], .article-content h3[id], .article-content h4[id], .article-content h5[id], .article-content h6[id]"; +const tocQuery = "#TableOfContents"; +const navigationQuery = "#TableOfContents li"; +const activeClass = "active-class"; + +function scrollToTocElement(tocElement: HTMLElement, scrollableNavigation: HTMLElement) { + let textHeight = tocElement.querySelector("a").offsetHeight; + let scrollTop = tocElement.offsetTop - scrollableNavigation.offsetHeight / 2 + textHeight / 2 - scrollableNavigation.offsetTop; + if (scrollTop < 0) { + scrollTop = 0; + } + scrollableNavigation.scrollTo({ top: scrollTop, behavior: "smooth" }); +} + +type IdToElementMap = { [key: string]: HTMLElement }; + +function buildIdToNavigationElementMap(navigation: NodeListOf): IdToElementMap { + const sectionLinkRef: IdToElementMap = {}; + navigation.forEach((navigationElement: HTMLElement) => { + const link = navigationElement.querySelector("a"); + const href = link.getAttribute("href"); + if (href.startsWith("#")) { + sectionLinkRef[href.slice(1)] = navigationElement; + } + }); + + return sectionLinkRef; +} + +function computeOffsets(headers: NodeListOf) { + let sectionsOffsets = []; + headers.forEach((header: HTMLElement) => { sectionsOffsets.push({ id: header.id, offset: header.offsetTop }) }); + sectionsOffsets.sort((a, b) => a.offset - b.offset); + return sectionsOffsets; +} + +function setupScrollspy() { + let headers = document.querySelectorAll(headersQuery); + if (!headers) { + console.warn("No header matched query", headers); + return; + } + + let scrollableNavigation = document.querySelector(tocQuery) as HTMLElement | undefined; + if (!scrollableNavigation) { + console.warn("No toc matched query", tocQuery); + return; + } + + let navigation = document.querySelectorAll(navigationQuery); + if (!navigation) { + console.warn("No navigation matched query", navigationQuery); + return; + } + + let sectionsOffsets = computeOffsets(headers); + + // We need to avoid scrolling when the user is actively interacting with the ToC. Otherwise, if the user clicks on a link in the ToC, + // we would scroll their view, which is not optimal usability-wise. + let tocHovered: boolean = false; + scrollableNavigation.addEventListener("mouseenter", debounced(() => tocHovered = true)); + scrollableNavigation.addEventListener("mouseleave", debounced(() => tocHovered = false)); + + let activeSectionLink: Element; + + let idToNavigationElement: IdToElementMap = buildIdToNavigationElementMap(navigation); + + function scrollHandler() { + let scrollPosition = document.documentElement.scrollTop || document.body.scrollTop; + + let newActiveSection: HTMLElement | undefined; + + // Find the section that is currently active. + // It is possible for no section to be active, so newActiveSection may be undefined. + sectionsOffsets.forEach((section) => { + if (scrollPosition >= section.offset - 20) { + newActiveSection = document.getElementById(section.id); + } + }); + + // Find the link for the active section. Once again, there are a few edge cases: + // - No active section = no link => undefined + // - No active section but the link does not exist in toc (e.g. because it is outside of the applicable ToC levels) => undefined + let newActiveSectionLink: HTMLElement | undefined + if (newActiveSection) { + newActiveSectionLink = idToNavigationElement[newActiveSection.id]; + } + + if (newActiveSection && !newActiveSectionLink) { + // The active section does not have a link in the ToC, so we can't scroll to it. + console.debug("No link found for section", newActiveSection); + } else if (newActiveSectionLink !== activeSectionLink) { + if (activeSectionLink) + activeSectionLink.classList.remove(activeClass); + if (newActiveSectionLink) { + newActiveSectionLink.classList.add(activeClass); + if (!tocHovered) { + // Scroll so that newActiveSectionLink is in the middle of scrollableNavigation, except when it's from a manual click (hence the tocHovered check) + scrollToTocElement(newActiveSectionLink, scrollableNavigation); + } + } + activeSectionLink = newActiveSectionLink; + } + } + + window.addEventListener("scroll", debounced(scrollHandler)); + + // Resizing may cause the offset values to change: recompute them. + function resizeHandler() { + sectionsOffsets = computeOffsets(headers); + scrollHandler(); + } + + window.addEventListener("resize", debounced(resizeHandler)); +} + +export { setupScrollspy }; \ No newline at end of file diff --git a/assets/ts/smoothAnchors.ts b/assets/ts/smoothAnchors.ts new file mode 100644 index 0000000..0718bf5 --- /dev/null +++ b/assets/ts/smoothAnchors.ts @@ -0,0 +1,34 @@ +// Implements smooth scrolling when clicking on an anchor link. +// This is required instead of using modern CSS because Chromium does not currently support scrolling +// one element with scrollTo while another element is scrolled because of a click on a link. This would +// thus not work with the ToC scrollspy and e.g. footnotes. + +// Here are additional links about this issue: +// - https://stackoverflow.com/questions/49318497/google-chrome-simultaneously-smooth-scrollintoview-with-more-elements-doesn +// - https://stackoverflow.com/questions/57214373/scrollintoview-using-smooth-function-on-multiple-elements-in-chrome +// - https://bugs.chromium.org/p/chromium/issues/detail?id=833617 +// - https://bugs.chromium.org/p/chromium/issues/detail?id=1043933 +// - https://bugs.chromium.org/p/chromium/issues/detail?id=1121151 + +const anchorLinksQuery = "a[href]"; + +function setupSmoothAnchors() { + document.querySelectorAll(anchorLinksQuery).forEach(aElement => { + let href = aElement.getAttribute("href"); + if (!href.startsWith("#")) { + return; + } + aElement.addEventListener("click", clickEvent => { + clickEvent.preventDefault(); + + let targetId = aElement.getAttribute("href").substring(1); + // The replace done on ':' is here for footnotes, as this character would otherwise interfere when used as a CSS selector. + let target = document.querySelector(`#${targetId.replace(":", "\\:")}`) as HTMLElement; + + window.history.pushState({}, "", aElement.getAttribute("href")); + scrollTo({ top: target.offsetTop, behavior: "smooth" }); + }); + }); +} + +export { setupSmoothAnchors }; \ No newline at end of file diff --git a/config.yaml b/config.yaml index 0e5283c..93367bb 100644 --- a/config.yaml +++ b/config.yaml @@ -19,6 +19,7 @@ params: lastUpdated: Jan 02, 2006 15:04 MST sidebar: + compact: false emoji: subtitle: avatar: @@ -95,6 +96,7 @@ params: darkTheme: reactionsEnabled: 1 emitMetadata: 0 + lang: gitalk: owner: @@ -108,16 +110,8 @@ params: id: widgets: - enabled: - - search - - archives - - tag-cloud - - archives: - limit: 5 - - tagCloud: - limit: 10 + homepage: [] + page: [] opengraph: twitter: diff --git a/data/external.yaml b/data/external.yaml index 427c8aa..fd2b665 100644 --- a/data/external.yaml +++ b/data/external.yaml @@ -36,3 +36,11 @@ KaTeX: integrity: sha384-vZTG03m+2yp6N6BNi5iM4rW4oIwk5DfcNdFfxkk9ZWpDriOkXX8voJBFrAO7MpVl type: script defer: true + +Cactus: + - src: https://latest.cactus.chat/cactus.js + integrity: + type: script + - src: https://latest.cactus.chat/style.css + integrity: + type: style diff --git a/exampleSite/config.yaml b/exampleSite/config.yaml index 28958ae..84585b4 100644 --- a/exampleSite/config.yaml +++ b/exampleSite/config.yaml @@ -4,6 +4,21 @@ theme: hugo-theme-stack paginate: 5 title: Example Site +languages: + en: + languageName: English + title: Example Site + weight: 1 + cn: + languageName: 中文 + title: 演示站点 + weight: 2 + ar: + languageName: عربي + languagedirection: rtl + title: موقع تجريبي + weight: 3 + # Change it to your Disqus shortname before using disqusShortname: hugo-theme-stack @@ -11,7 +26,7 @@ disqusShortname: hugo-theme-stack googleAnalytics: # Theme i18n support -# Available values: en, fr, id, ja, ko, pt-br, zh-cn, zh-tw, es, de, nl, it, th, el, uk +# Available values: en, fr, id, ja, ko, pt-br, zh-cn, zh-tw, es, de, nl, it, th, el, uk, ar DefaultContentLanguage: en # Set hasCJKLanguage to true if DefaultContentLanguage is in [zh-cn ja ko] @@ -104,6 +119,12 @@ params: path: lang: + # See https://cactus.chat/docs/reference/web-client/#configuration for description of the various options + cactus: + defaultHomeserverUrl: "https://matrix.cactus.chat:8448" + serverName: "cactus.chat" + siteName: "" # You must insert a unique identifier here matching the one you registered (See https://cactus.chat/docs/getting-started/quick-start/#register-your-site) + giscus: repo: repoID: @@ -116,27 +137,29 @@ params: emitMetadata: 0 gitalk: - owner: - admin: - repo: - clientID: - clientSecret: - + owner: + admin: + repo: + clientID: + clientSecret: + cusdis: - host: - id: - + host: + id: widgets: - enabled: - - search - - archives - - tag-cloud - - archives: - limit: 5 - - tagCloud: - limit: 10 + homepage: + - type: search + - type: archives + params: + limit: 5 + - type: categories + params: + limit: 10 + - type: tag-cloud + params: + limit: 10 + page: + - type: toc opengraph: twitter: @@ -169,28 +192,20 @@ params: ### See https://docs.stack.jimmycai.com/configuration/custom-menu.html ### To remove about, archive and search page menu item, remove `menu` field from their FrontMatter menu: - main: - - identifier: home - name: Home - url: / - weight: -100 - params: - ### For demonstration purpose, the home link will be open in a new tab - newTab: true - icon: home - + main: [] + social: - identifier: github name: GitHub url: https://github.com/CaiJimmy/hugo-theme-stack params: - icon: brand-github - + icon: brand-github + - identifier: twitter name: Twitter url: https://twitter.com params: - icon: brand-twitter + icon: brand-twitter related: includeNewer: true @@ -214,3 +229,9 @@ markup: startLevel: 2 highlight: noClasses: false + codeFences: true + guessSyntax: true + lineNoStart: 1 + lineNos: true + lineNumbersInTable: true + tabWidth: 4 diff --git a/exampleSite/content/_index.cn.md b/exampleSite/content/_index.cn.md new file mode 100644 index 0000000..e4b59a8 --- /dev/null +++ b/exampleSite/content/_index.cn.md @@ -0,0 +1,8 @@ +--- +menu: + main: + name: 主页 + weight: -100 + params: + icon: home +--- \ No newline at end of file diff --git a/exampleSite/content/_index.md b/exampleSite/content/_index.md new file mode 100644 index 0000000..530dfe8 --- /dev/null +++ b/exampleSite/content/_index.md @@ -0,0 +1,8 @@ +--- +menu: + main: + name: Home + weight: -100 + params: + icon: home +--- \ No newline at end of file diff --git a/exampleSite/content/page/about/index.cn.md b/exampleSite/content/page/about/index.cn.md new file mode 100644 index 0000000..060c006 --- /dev/null +++ b/exampleSite/content/page/about/index.cn.md @@ -0,0 +1,5 @@ +--- +title: 关于 +--- + +This is a test page for i18n support. \ No newline at end of file diff --git a/exampleSite/content/post/chinese-test/index.md b/exampleSite/content/post/chinese-test/index.cn.md similarity index 100% rename from exampleSite/content/post/chinese-test/index.md rename to exampleSite/content/post/chinese-test/index.cn.md diff --git a/exampleSite/content/post/markdown-syntax/index.md b/exampleSite/content/post/markdown-syntax/index.md index be381a0..0254cca 100644 --- a/exampleSite/content/post/markdown-syntax/index.md +++ b/exampleSite/content/post/markdown-syntax/index.md @@ -162,3 +162,7 @@ Xn + Yn = Zn Press CTRL+ALT+Delete to end the session. Most salamanders are nocturnal, and hunt for insects, worms, and other small creatures. + +## Hyperlinked image + +[![Google](https://www.google.com/images/branding/googlelogo/1x/googlelogo_light_color_272x92dp.png)](https://google.com) \ No newline at end of file diff --git a/exampleSite/content/post/placeholder-text/index.ar.md b/exampleSite/content/post/placeholder-text/index.ar.md new file mode 100644 index 0000000..b0328f4 --- /dev/null +++ b/exampleSite/content/post/placeholder-text/index.ar.md @@ -0,0 +1,32 @@ ++++ +author = "Hugo Authors" +title = "مثال نص" +date = "2019-03-09" +description = "هذا النص هو مثال لنص يمكن أن يستبدل في نفس المساحة" +categories = [ + "تجربة", + "تجربة مع فراغات" +] +tags = [ + "ماركداون", + "نص", + "وسم مع فراغات" +] +image = "matt-le-SJSpo9hQf7s-unsplash.jpg" ++++ +## فقرة 1 + +هذا النص هو مثال لنص يمكن أن يستبدل في نفس المساحة، لقد تم توليد هذا النص من [مولد النص العربى](https://colorslab.com/textgator/)، حيث يمكنك أن تولد مثل هذا النص أو العديد من النصوص الأخرى إضافة إلى زيادة عدد الحروف التى يولدها التطبيق. +إذا كنت تحتاج إلى عدد أكبر من الفقرات يتيح لك مولد النص العربى زيادة عدد الفقرات كما تريد، النص لن يبدو مقسما ولا يحوي أخطاء لغوية، مولد النص العربى مفيد لمصممي المواقع على وجه الخصوص، حيث يحتاج العميل فى كثير من الأحيان أن يطلع على صورة حقيقية لتصميم الموقع. +ومن هنا وجب على المصمم أن يضع نصوصا مؤقتة على التصميم ليظهر للعميل الشكل كاملاً،دور مولد النص العربى أن يوفر على المصمم عناء البحث عن نص بديل لا علاقة له بالموضوع الذى يتحدث عنه التصميم فيظهر بشكل لا يليق. +هذا النص يمكن أن يتم تركيبه على أي تصميم دون مشكلة فلن يبدو وكأنه نص منسوخ، غير منظم، غير منسق، أو حتى غير مفهوم. لأنه مازال نصاً بديلاً ومؤقتاً. + +## فقرة 2 + +هذا النص هو مثال لنص يمكن أن يستبدل في نفس المساحة، لقد تم توليد هذا النص من [مولد النص العربى](https://colorslab.com/textgator/)، حيث يمكنك أن تولد مثل هذا النص أو العديد من النصوص الأخرى إضافة إلى زيادة عدد الحروف التى يولدها التطبيق. +إذا كنت تحتاج إلى عدد أكبر من الفقرات يتيح لك مولد النص العربى زيادة عدد الفقرات كما تريد، النص لن يبدو مقسما ولا يحوي أخطاء لغوية، مولد النص العربى مفيد لمصممي المواقع على وجه الخصوص، حيث يحتاج العميل فى كثير من الأحيان أن يطلع على صورة حقيقية لتصميم الموقع. +ومن هنا وجب على المصمم أن يضع نصوصا مؤقتة على التصميم ليظهر للعميل الشكل كاملاً،دور مولد النص العربى أن يوفر على المصمم عناء البحث عن نص بديل لا علاقة له بالموضوع الذى يتحدث عنه التصميم فيظهر بشكل لا يليق. +هذا النص يمكن أن يتم تركيبه على أي تصميم دون مشكلة فلن يبدو وكأنه نص منسوخ، غير منظم، غير منسق، أو حتى غير مفهوم. لأنه مازال نصاً بديلاً ومؤقتاً. + +## تجربة RTL +كلمة 1 Text كلمة 2 diff --git a/exampleSite/content/post/rich-content/index.md b/exampleSite/content/post/rich-content/index.md index af8a390..f2b45db 100644 --- a/exampleSite/content/post/rich-content/index.md +++ b/exampleSite/content/post/rich-content/index.md @@ -36,3 +36,7 @@ Hugo ships with several [Built-in Shortcodes](https://gohugo.io/content-manageme ## bilibilibi Shortcode {{< bilibili av498363026 >}} + +## Gist Shortcode + +{{< gist spf13 7896402 >}} \ No newline at end of file diff --git a/i18n/ar.yaml b/i18n/ar.yaml new file mode 100644 index 0000000..dab3c74 --- /dev/null +++ b/i18n/ar.yaml @@ -0,0 +1,70 @@ +toggleMenu: + other: اخفي القائمة + +darkMode: + other: الوضع الداكن + +list: + page: + one: "{{ .Count }} صفحه" + other: "{{ .Count }} صفحات" + + section: + other: قسم + + subsection: + one: قسم فرعي + other: اقسام فرعية + +article: + back: + other: خلف + + tableOfContents: + other: جدول المحتويات + + relatedContents: + other: محتوى مشابهه + + lastUpdatedOn: + other: التعديل الاخير + + readingTime: + one: "قرائة {{ .Count }} دقيقة" + other: "قرائة {{ .Count }} دقائق" + +notFound: + title: + other: غير موجود + + subtitle: + other: تعذر العثور على الصفحة المطلوبة. + +widget: + archives: + title: + other: الارشيفات + + more: + other: اكثر + + tagCloud: + title: + other: وسوم + +search: + title: + other: بحث + + placeholder: + other: اكتب... + + resultTitle: + other: "#PAGES_COUNT نتيجة (#TIME_SECONDS ثواني)" + +footer: + builtWith: + other: "مبني بستخدام {{ .Generator }}" + + designedBy: + other: "قالب {{ .Theme }} مصمم من {{ .DesignedBy }}" diff --git a/i18n/en.yaml b/i18n/en.yaml index 0197d89..3d0ed03 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -51,6 +51,9 @@ widget: tagCloud: title: other: Tags + categoriesCloud: + title: + other: Categories search: title: diff --git a/layouts/_default/_markup/render-image.html b/layouts/_default/_markup/render-image.html index 0325458..0ed5584 100644 --- a/layouts/_default/_markup/render-image.html +++ b/layouts/_default/_markup/render-image.html @@ -25,22 +25,17 @@ {{- end -}} {{- end -}} - \ No newline at end of file +> \ No newline at end of file diff --git a/layouts/_default/archives.html b/layouts/_default/archives.html index 321aa6d..5d5243c 100644 --- a/layouts/_default/archives.html +++ b/layouts/_default/archives.html @@ -1,17 +1,19 @@ {{ define "body-class" }}template-archives{{ end }} {{ define "main" }} - {{- $taxonomy := $.Site.GetPage "taxonomyTerm" "categories" -}} - {{- $terms := $taxonomy.Pages -}} - {{ if $terms }} -

{{ $taxonomy.Title }}

-
-
- {{ range $terms }} - {{ partial "article-list/tile" (dict "context" . "size" "250x150" "Type" "taxonomy") }} - {{ end }} +
+ {{- $taxonomy := $.Site.GetPage "taxonomyTerm" "categories" -}} + {{- $terms := $taxonomy.Pages -}} + {{ if $terms }} +

{{ $taxonomy.Title }}

+
+
+ {{ range $terms }} + {{ partial "article-list/tile" (dict "context" . "size" "250x150" "Type" "taxonomy") }} + {{ end }} +
-
- {{ end }} + {{ end }} + {{ $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections }} {{ $notHidden := where .Site.RegularPages "Params.hidden" "!=" true }} diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html index 8a5ff95..081277b 100644 --- a/layouts/_default/baseof.html +++ b/layouts/_default/baseof.html @@ -1,12 +1,20 @@ - + {{- partial "head/head.html" . -}} {{- block "head" . -}}{{ end }} {{- partial "head/colorScheme" . -}} -
+ + {{/* The container is wider when there's any activated widget */}} + {{- $hasWidget := false -}} + {{- range .Site.Params.widgets -}} + {{- if gt (len .) 0 -}} + {{- $hasWidget = true -}} + {{- end -}} + {{- end -}} +
{{- block "left-sidebar" . -}} {{ partial "sidebar/left.html" . }} {{- end -}} diff --git a/layouts/_default/list.html b/layouts/_default/list.html index 90e5b66..9bc618d 100644 --- a/layouts/_default/list.html +++ b/layouts/_default/list.html @@ -1,46 +1,48 @@ {{ define "main" }} -

- {{ if eq .Parent (.GetPage "/") }} - {{ T "list.section" }} - {{ else }} - {{ .Parent.Title }} - {{ end }} -

- -
-
-

{{ T "list.page" (len .Pages) }}

-

{{ .Title }}

- {{ with .Params.description }} -

{{ . }}

+
+

+ {{ if eq .Parent (.GetPage "/") }} + {{ T "list.section" }} + {{ else }} + {{ .Parent.Title }} {{ end }} -

+ - {{- $image := partialCached "helper/image" (dict "Context" . "Type" "section") .RelPermalink "section" -}} - {{ if $image.exists }} -
- {{ if $image.resource }} - {{- $Permalink := $image.resource.RelPermalink -}} - {{- $Width := $image.resource.Width -}} - {{- $Height := $image.resource.Height -}} - - {{- if (default true .Page.Site.Params.imageProcessing.cover.enabled) -}} - {{- $thumbnail := $image.resource.Fill "120x120" -}} - {{- $Permalink = $thumbnail.RelPermalink -}} - {{- $Width = $thumbnail.Width -}} - {{- $Height = $thumbnail.Height -}} - {{- end -}} - - - {{ else }} - +
+
+

{{ T "list.page" (len .Pages) }}

+

{{ .Title }}

+ {{ with .Params.description }} +

{{ . }}

{{ end }}
- {{ end }} -
+ + {{- $image := partialCached "helper/image" (dict "Context" . "Type" "section") .RelPermalink "section" -}} + {{ if $image.exists }} +
+ {{ if $image.resource }} + {{- $Permalink := $image.resource.RelPermalink -}} + {{- $Width := $image.resource.Width -}} + {{- $Height := $image.resource.Height -}} + + {{- if (default true .Page.Site.Params.imageProcessing.cover.enabled) -}} + {{- $thumbnail := $image.resource.Fill "120x120" -}} + {{- $Permalink = $thumbnail.RelPermalink -}} + {{- $Width = $thumbnail.Width -}} + {{- $Height = $thumbnail.Height -}} + {{- end -}} + + + {{ else }} + + {{ end }} +
+ {{ end }} +
+ {{- $subsections := .Sections -}} {{- $pages := .Pages | complement $subsections -}} @@ -53,14 +55,16 @@ {{- end -}} {{- with $subsections -}} -

{{ T "list.subsection" (len $subsections) }}

-
-
- {{ range . }} - {{ partial "article-list/tile" (dict "context" . "size" "250x150" "Type" "section") }} - {{ end }} +
+ {{- end -}} {{/* List only pages that are not a subsection */}} @@ -77,5 +81,5 @@ {{ end }} {{ define "right-sidebar" }} - {{ partialCached "sidebar/right.html" . }} + {{ partial "sidebar/right.html" (dict "Context" . "Scope" "homepage") }} {{ end }} \ No newline at end of file diff --git a/layouts/rss.xml b/layouts/_default/rss.xml similarity index 86% rename from layouts/rss.xml rename to layouts/_default/rss.xml index 178f997..e9f3f1a 100644 --- a/layouts/rss.xml +++ b/layouts/_default/rss.xml @@ -1,9 +1,15 @@ -{{- $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections -}} -{{- $notHidden := where .Site.RegularPages "Params.hidden" "!=" true -}} -{{- $filtered := ($pages | intersect $notHidden) -}} +{{- $pctx := . -}} +{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}} +{{- $pages := slice -}} +{{- if or $.IsHome $.IsSection -}} +{{- $pages = $pctx.RegularPages -}} +{{- else -}} +{{- $pages = $pctx.Pages -}} +{{- end -}} +{{- $pages := where $pages "Params.hidden" "!=" true -}} {{- $limit := .Site.Config.Services.RSS.Limit -}} {{- if ge $limit 1 -}} -{{- $filtered = $filtered | first $limit -}} +{{- $pages = $pages | first $limit -}} {{- end -}} {{- printf "" | safeHTML }} @@ -20,7 +26,7 @@ {{- with .OutputFormats.Get "RSS" -}} {{ printf "" .Permalink .MediaType | safeHTML }} {{- end -}} - {{ range $filtered }} + {{ range $pages }} {{- $content := safeHTML (.Summary | html) -}} {{- if .Site.Params.rssFullContent -}} {{- $content = safeHTML (.Content | html) -}} diff --git a/layouts/_default/single.html b/layouts/_default/single.html index 8ce42e9..c70df20 100644 --- a/layouts/_default/single.html +++ b/layouts/_default/single.html @@ -1,15 +1,26 @@ {{ define "body-class" }} - {{ $TOCEnabled := default (default false .Site.Params.article.toc) .Params.toc }} - {{- .Scratch.Set "hasTOC" (and (ge (len .TableOfContents) 100) $TOCEnabled) -}} - article-page {{ if (.Scratch.Get "hasTOC") }}has-toc{{ end }} -{{ end }} + article-page + {{/* + Enable the right sidebar if + - Widget different from 'TOC' is enabled + - TOC is enabled and not empty + */}} + {{- $HasWidgetNotTOC := false -}} + {{- $TOCWidgetEnabled := false -}} + {{- range .Site.Params.widgets.page -}} + {{- if ne .type "toc" -}} + {{ $HasWidgetNotTOC = true -}} + {{- else -}} + {{ $TOCWidgetEnabled = true -}} + {{- end -}} + {{- end -}} -{{ define "container-class" }} - {{ if (.Scratch.Get "hasTOC") }} - extended - {{ else }} - on-phone--column {{ if .Site.Params.widgets.enabled }}extended{{ else }}compact{{ end }} - {{ end }} + {{- $TOCManuallyDisabled := eq .Params.toc false -}} + {{- $TOCEnabled := and (not $TOCManuallyDisabled) $TOCWidgetEnabled -}} + {{- $hasTOC := ge (len .TableOfContents) 100 -}} + {{- .Scratch.Set "TOCEnabled" (and $TOCEnabled $hasTOC) -}} + + {{- .Scratch.Set "hasWidget" (or $HasWidgetNotTOC (and $TOCEnabled $hasTOC)) -}} {{ end }} {{ define "main" }} @@ -30,32 +41,6 @@ {{ partialCached "article/components/photoswipe" . }} {{ end }} -{{ define "left-sidebar" }} - {{ if (.Scratch.Get "hasTOC") }} -
- - {{ (resources.Get "icons/back.svg").Content | safeHTML }} - {{ T "article.back" }} - -
- {{ else }} - {{ partial "sidebar/left.html" . }} - {{ end }} -{{ end }} - {{ define "right-sidebar" }} - {{ if (.Scratch.Get "hasTOC") }} - - {{ end }} + {{ if .Scratch.Get "hasWidget" }}{{ partial "sidebar/right.html" (dict "Context" . "Scope" "page") }}{{ end}} {{ end }} diff --git a/layouts/index.html b/layouts/index.html index 576faef..0cd0b88 100644 --- a/layouts/index.html +++ b/layouts/index.html @@ -15,5 +15,5 @@ {{ end }} {{ define "right-sidebar" }} - {{ partial "sidebar/right.html" . }} + {{ partial "sidebar/right.html" (dict "Context" . "Scope" "homepage") }} {{ end }} \ No newline at end of file diff --git a/layouts/page/search.html b/layouts/page/search.html index 935b384..7c424a6 100644 --- a/layouts/page/search.html +++ b/layouts/page/search.html @@ -16,8 +16,10 @@ -

-
+
+

+
+
+{{- end -}} diff --git a/layouts/partials/helper/external.html b/layouts/partials/helper/external.html index e1505d4..88d9525 100644 --- a/layouts/partials/helper/external.html +++ b/layouts/partials/helper/external.html @@ -8,7 +8,7 @@ integrity="{{ . }}" {{- end -}} crossorigin="anonymous" - defer="{{ default false .defer }}" + {{ if .defer }}defer{{ end }} > {{- else if eq .type "style" -}} diff --git a/layouts/partials/sidebar/left.html b/layouts/partials/sidebar/left.html index 4442dfe..f255389 100644 --- a/layouts/partials/sidebar/left.html +++ b/layouts/partials/sidebar/left.html @@ -1,11 +1,11 @@ -

{{ T "widget.archives.title" }}

- {{ $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections }} - {{ $notHidden := where .Site.RegularPages "Params.hidden" "!=" true }} + {{ $pages := where $context.Site.RegularPages "Type" "in" $context.Site.Params.mainSections }} + {{ $notHidden := where $context.Site.RegularPages "Params.hidden" "!=" true }} {{ $filtered := ($pages | intersect $notHidden) }} {{ $archives := $filtered.GroupByDate "2006" }}
- {{ range $index, $item := first (add .Site.Params.widgets.archives.limit 1) ($archives) }} + {{ range $index, $item := first (add $limit 1) ($archives) }} {{- $id := lower (replace $item.Key " " "-") -}}
- {{ if eq $index $.Site.Params.widgets.archives.limit }} + {{ if eq $index $limit }} {{ T "widget.archives.more" }} {{ else }} {{ .Key }} diff --git a/layouts/partials/widget/categories.html b/layouts/partials/widget/categories.html new file mode 100644 index 0000000..10c8a35 --- /dev/null +++ b/layouts/partials/widget/categories.html @@ -0,0 +1,16 @@ +{{- $context := .Context -}} +{{- $limit := default 10 .Params.limit -}} +
+
+ {{ partial "helper/icon" "categories" }} +
+

{{ T "widget.categoriesCloud.title" }}

+ +
+
diff --git a/layouts/partials/widget/search.html b/layouts/partials/widget/search.html index 833f740..7b0fc73 100644 --- a/layouts/partials/widget/search.html +++ b/layouts/partials/widget/search.html @@ -1,4 +1,4 @@ -{{- $query := first 1 (where .Site.Pages "Layout" "==" "search") -}} +{{- $query := first 1 (where .Context.Site.Pages "Layout" "==" "search") -}} {{- if $query -}} {{- $searchPage := index $query 0 -}}
diff --git a/layouts/partials/widget/tag-cloud.html b/layouts/partials/widget/tag-cloud.html index 99b8e59..e64e5e2 100644 --- a/layouts/partials/widget/tag-cloud.html +++ b/layouts/partials/widget/tag-cloud.html @@ -1,3 +1,5 @@ +{{- $context := .Context -}} +{{- $limit := default 10 .Params.limit -}}
{{ partial "helper/icon" "tag" }} @@ -5,7 +7,7 @@

{{ T "widget.tagCloud.title" }}

- {{ range first .Site.Params.widgets.tagCloud.limit .Site.Taxonomies.tags.ByCount }} + {{ range first $limit $context.Site.Taxonomies.tags.ByCount }} {{ .Page.Title }} diff --git a/layouts/partials/widget/toc.html b/layouts/partials/widget/toc.html new file mode 100644 index 0000000..e311de3 --- /dev/null +++ b/layouts/partials/widget/toc.html @@ -0,0 +1,12 @@ +{{ if (.Context.Scratch.Get "TOCEnabled") }} +
+
+ {{ partial "helper/icon" "hash" }} +
+

{{ T "article.tableOfContents" }}

+ +
+ {{ .Context.TableOfContents }} +
+
+{{ end }} \ No newline at end of file