feat(assets): add table auto-numbering and sorting support

- Add CSS counter-based table auto-numbering with i18n support
- Add client-side table sorting with numeric/string/date detection
- Support per-table opt-out via Markdown attributes (number=false, sort=false)
- Add table caption support via {caption="..."} attribute
- Extract table styles from _content.scss to dedicated _table.scss
- Add TableConfig TypeScript interface
- Register table-sort script via .Store.hasTableSort for lazy loading
This commit is contained in:
Cell
2026-08-31 17:11:39 +08:00
parent a9c7fc2641
commit 7a2c04cba7
28 changed files with 349 additions and 57 deletions
@@ -12,6 +12,7 @@ tags:
This post is a test post to preview the color syntax in Markdown.
<!--more-->
<!-- markdownlint-disable MD055 MD056 -->
## 📝 Syntax
@@ -20,6 +21,7 @@ This post is a test post to preview the color syntax in Markdown.
| HEX | `` `#RRGGBB` `` | `` `#0969DA` `` | `#0969DA` |
| RGB | `` `rgb(R,G,B)` `` | `` `rgb(9, 105, 218)` `` | `rgb(9, 105, 218)` |
| HSL | `` `hsl(H,S,L)` `` | `` `hsl(212, 92%, 45%)` `` | `hsl(212, 92%, 45%)` |
{caption="Color syntax in Markdown"}
e.g. The background color is `#ffffff` for light mode and `#000000` for dark mode.
+154
View File
@@ -0,0 +1,154 @@
/**
* Table sort module for FixIt content blocks.
*
* Responsibilities:
* - Initialize sortable tables (marked with `data-sortable` attribute).
* - Add click handlers to `<th>` elements for ascending/descending/no-sort toggle.
* - Support numeric, date, and string sorting.
* - Re-initialize after content decryption events.
*/
import { eventBus } from '../core/event-bus'
type SortDirection = 'asc' | 'desc' | null
function detectValueType(value: string): 'number' | 'date' | 'string' {
const stripped = value.replace(/[\s,%$€£¥]/g, '')
if (stripped !== '' && !Number.isNaN(Number(stripped)))
return 'number'
if (/\d{4}[-/]\d{1,2}[-/]\d{1,2}/.test(value) && !Number.isNaN(Date.parse(value)))
return 'date'
return 'string'
}
function compareValues(a: string, b: string, direction: SortDirection): number {
if (!direction)
return 0
const type = detectValueType(a)
let result = 0
switch (type) {
case 'number':
result = Number(a.replace(/[\s,%$€£¥]/g, '')) - Number(b.replace(/[\s,%$€£¥]/g, ''))
break
case 'date':
result = new Date(a).getTime() - new Date(b).getTime()
break
default:
result = a.localeCompare(b)
}
return direction === 'asc' ? result : -result
}
function nextDirection(current: SortDirection): SortDirection {
if (current === null)
return 'asc'
if (current === 'asc')
return 'desc'
return null
}
function sortTable(table: HTMLTableElement, colIndex: number, direction: SortDirection) {
const tbody = table.querySelector('tbody')
if (!tbody)
return
const rows = Array.from(tbody.querySelectorAll('tr'))
if (!direction) {
rows.sort((a, b) => {
const orderA = parseInt(a.dataset.order || '0', 10)
const orderB = parseInt(b.dataset.order || '0', 10)
return orderA - orderB
})
}
else {
rows.sort((a, b) => {
const cellA = a.children[colIndex]?.textContent?.trim() || ''
const cellB = b.children[colIndex]?.textContent?.trim() || ''
return compareValues(cellA, cellB, direction)
})
}
rows.forEach(row => tbody.appendChild(row))
}
function updateSortIndicators(table: HTMLTableElement, colIndex: number, direction: SortDirection) {
table.querySelectorAll('th').forEach((th, i) => {
th.classList.remove('sort-asc', 'sort-desc')
if (i === colIndex && direction) {
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc')
}
})
}
function initSortableTable(table: HTMLTableElement) {
if (table.dataset.sortInit)
return
table.dataset.sortInit = 'true'
const tbody = table.querySelector('tbody')
if (tbody) {
Array.from(tbody.querySelectorAll('tr')).forEach((row, i) => {
row.dataset.order = String(i)
})
}
table.classList.add('is-sortable')
const headers = table.querySelectorAll('thead th')
headers.forEach((th, index) => {
th.classList.add('sort-header')
th.setAttribute('role', 'button')
th.setAttribute('tabindex', '0')
th.setAttribute('aria-sort', 'none')
const handleClick = () => {
const currentDir = (table.dataset.sortDir === 'asc' && table.dataset.sortCol === String(index))
? 'asc'
: (table.dataset.sortDir === 'desc' && table.dataset.sortCol === String(index))
? 'desc'
: null
const newDir = nextDirection(currentDir)
if (newDir) {
table.dataset.sortCol = String(index)
table.dataset.sortDir = newDir
th.setAttribute('aria-sort', newDir === 'asc' ? 'ascending' : 'descending')
}
else {
delete table.dataset.sortCol
delete table.dataset.sortDir
th.setAttribute('aria-sort', 'none')
}
sortTable(table, index, newDir)
updateSortIndicators(table, index, newDir)
}
th.addEventListener('click', handleClick, false)
th.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Enter' || (e as KeyboardEvent).key === ' ') {
e.preventDefault()
handleClick()
}
}, false)
})
}
function initTableSort(target: Element | Document = document) {
target.querySelectorAll<HTMLTableElement>('table[data-sortable]:not([data-sort-init])')
.forEach(initSortableTable)
}
function bindEvents() {
eventBus.on('fixit:content-decrypted', ({ detail }) => {
initTableSort(detail.target)
})
}
document.addEventListener('DOMContentLoaded', () => {
initTableSort()
bindEvents()
}, false)
+6
View File
@@ -32,6 +32,7 @@ export interface FixItConfig {
encryption?: EncryptionConfig
print?: PrintConfig
postChat?: boolean
table?: TableConfig
}
export interface EchartsConfig {
@@ -121,3 +122,8 @@ export interface PrintConfig {
expandDetails?: boolean
expandFileTree?: boolean
}
export interface TableConfig {
number?: boolean
sort?: boolean
}
+7 -48
View File
@@ -141,7 +141,7 @@
.content {
margin-block: 1rem;
counter-reset: h2 h3 h4 h5 h6;
counter-reset: h2 h3 h4 h5 h6 table-counter;
> h2.heading-element {
counter-increment: h2;
@@ -167,6 +167,10 @@
counter-increment: h6;
}
> .table-wrapper table[data-table-numbered] {
counter-increment: table-counter;
}
> h2.heading-element .heading-numbered::before {
content: counter(h2) ". ";
}
@@ -358,53 +362,8 @@
}
}
.table-wrapper {
position: relative;
overflow-x: auto;
overflow-y: hidden;
margin: 0.5rem 0;
background: fi-var(table-background-color);
border: 1px solid fi-var(table-border-color);
@include border-radius;
@include set-fi-var(scrollbar-thumb-color, fi-var(table-background-color));
> table {
width: 100%;
max-width: 100%;
margin: 0;
border-spacing: 0;
background: fi-var(table-background-color);
border-collapse: separate;
thead {
background: fi-var(table-thead-color);
}
&:not([class]) tbody {
& tr:nth-child(odd) {
background: light-dark(color.adjust($table-background-color, $lightness: -2.25%), color.adjust($table-background-color-dark, $lightness: 2.75%));
}
& tr:hover {
background: light-dark(color.adjust($table-background-color, $lightness: -4.5%), color.adjust($table-background-color-dark, $lightness: 5.5%));
}
}
th,
td {
padding: 0.3rem 1rem;
border-inline-end: 1px solid fi-var(table-border-color);
border-bottom: 1px solid fi-var(table-border-color);
}
tr > :last-child {
border-inline-end: none;
}
> :last-child > tr:last-child > * {
border-bottom: none;
}
}
> .table-wrapper table[data-table-numbered] {
counter-increment: table-counter;
}
img {
+1
View File
@@ -2,5 +2,6 @@
@forward "shortcodes";
@forward "alert";
@forward "code";
@forward "table";
@forward "fixit-decryptor";
@forward "patch";
+114
View File
@@ -0,0 +1,114 @@
@use "sass:color";
@use "core/functions" as *;
@use "core/mixins" as *;
@use "variables" as *;
.table-wrapper {
position: relative;
overflow-x: auto;
overflow-y: hidden;
margin: 0.5rem 0;
background: fi-var(table-background-color);
border: 1px solid fi-var(table-border-color);
@include border-radius;
@include set-fi-var(scrollbar-thumb-color, fi-var(table-background-color));
> table {
width: 100%;
max-width: 100%;
margin: 0;
border-spacing: 0;
background: fi-var(table-background-color);
border-collapse: separate;
thead {
background: fi-var(table-thead-color);
}
tbody {
tr:nth-child(odd) {
background: light-dark(color.adjust($table-background-color, $lightness: -2.25%), color.adjust($table-background-color-dark, $lightness: 2.75%));
}
tr:hover {
background: light-dark(color.adjust($table-background-color, $lightness: -4.5%), color.adjust($table-background-color-dark, $lightness: 5.5%));
}
}
th,
td {
padding: 0.3rem 1rem;
border-inline-end: 1px solid fi-var(table-border-color);
border-bottom: 1px solid fi-var(table-border-color);
}
tr > :last-child {
border-inline-end: none;
}
> :last-child > tr:last-child > * {
border-bottom: none;
}
> caption {
caption-side: top;
padding: 0.5rem 1rem;
text-align: center;
font-weight: 600;
font-size: 0.875em;
border-bottom: 1px solid fi-var(table-border-color);
border-radius: fi-var(global-border-radius) fi-var(global-border-radius) 0 0;
.table-number {
&::before {
content: attr(data-i18n-table) " " counter(table-counter);
}
& + .table-caption {
font-weight: normal;
&::before {
content: "-";
margin-inline: 0.25em;
}
}
}
}
// Sortable table styles
&.is-sortable thead th.sort-header {
cursor: pointer;
user-select: none;
position: relative;
padding-inline-end: 1.5rem;
&::after {
content: "";
position: absolute;
inset-inline-end: 0.5rem;
top: 50%;
translate: 0 -50%;
width: 0;
height: 0;
border-inline: 4px solid transparent;
border-block: 4px solid transparent;
opacity: 0.3;
transition: all 0.2s, translate 0.2s;
}
&:hover::after {
opacity: 0.6;
}
&.sort-asc::after,
&.sort-desc::after {
opacity: 1;
border-block-end: 5px solid fi-var(global-font-color);
border-block-start: 0;
}
&.sort-desc::after {
rotate: 180deg;
}
}
}
}
+8 -1
View File
@@ -735,7 +735,7 @@ enable = false
# Disclaimer type.
# available values: ["ai", "repost", "original", "custom"]
type = "ai"
# Custom disclaimer content (optional, uses i18n default if empty).
# Custom disclaimer content (optional, uses I18n default if empty).
content = ""
# Display a message at the beginning of an article to warn the reader that its content might be expired.
@@ -760,6 +760,13 @@ enable = false
# Only enable in main section pages (default is posts).
only_main_section = true
# {{< version 1.0.0 >}} Table configuration.
[params.table]
# Whether to enable auto table numbering.
number = false
# Whether to enable table sorting.
sort = true
# Mathematical formulas configuration.
# See: [Formula](https://fixit.lruihao.cn/docs/content-management/markdown-syntax/extended/#formula)
[params.math]
+1
View File
@@ -89,6 +89,7 @@ valinePlaceholder = "تعليقك ..."
# === Assets ===
[assets]
table = "جدول"
search = "بحث"
searchPlaceholder = "ابحث في العناوين أو المحتوى ..."
searchIn = "ابحث في {{ . }}"
+1
View File
@@ -88,6 +88,7 @@ valinePlaceholder = "Ihr Kommentar ..."
# === Assets ===
[assets]
table = "Tabelle"
search = "Suche"
searchPlaceholder = "Suche nach Titel und Inhalt..."
searchIn = "Suche in {{ . }}"
+1
View File
@@ -88,6 +88,7 @@ valinePlaceholder = "Your comment ..."
# === Assets ===
[assets]
table = "Table"
search = "Search"
searchPlaceholder = "Search titles or contents ..."
searchIn = "Search in {{ . }}"
+1
View File
@@ -88,6 +88,7 @@ valinePlaceholder = "Tu comentario ..."
# === Assets ===
[assets]
table = "Tabla"
search = "Buscar"
searchPlaceholder = "Busca títulos o contenido..."
searchIn = "Buscar en {{ . }}"
+1
View File
@@ -89,6 +89,7 @@ valinePlaceholder = "نظر شما ..."
# === Assets ===
[assets]
table = "جدول"
search = "جستجو"
searchPlaceholder = "جستجوی عناوین یا محتوا ..."
searchIn = "جستجو در {{ . }}"
+1
View File
@@ -88,6 +88,7 @@ valinePlaceholder = "Votre commentaire ..."
# === Assets ===
[assets]
table = "Tableau"
search = "Chercher"
searchPlaceholder = "Rechercher des titres, des contenus..."
searchIn = "Rechercher dans {{ . }}"
+1
View File
@@ -89,6 +89,7 @@ valinePlaceholder = "आपकी टिप्पणी ..."
# === Assets ===
[assets]
table = "तालिका"
search = "खोज"
searchPlaceholder = "शीर्षक या सामग्री खोजें ..."
searchIn = "{{ . }} में खोजें"
+1
View File
@@ -88,6 +88,7 @@ valinePlaceholder = "Il tuo commento ..."
# === Assets ===
[assets]
table = "Tabella"
search = "Cerca"
searchPlaceholder = "Cerca il titolo o il contenuto dell'articolo ..."
searchIn = "Cerca in"
+1
View File
@@ -86,6 +86,7 @@ valinePlaceholder = "あなたのコメント……"
# === Assets ===
[assets]
table = "テーブル"
search = "検索"
searchPlaceholder = "タイトルまたは内容を検索..."
searchIn = "{{ . }} で検索"
+1
View File
@@ -86,6 +86,7 @@ valinePlaceholder = "당신의 댓글……"
# === Assets ===
[assets]
table = "표"
search = "검색"
searchPlaceholder = "제목 또는 내용을 검색하세요..."
searchIn = "{{ . }}에서 검색"
+1
View File
@@ -88,6 +88,7 @@ valinePlaceholder = "Twój komentarz ..."
# === Assets ===
[assets]
table = "Tabela"
search = "Szukaj"
searchPlaceholder = "Wyszukaj tytuł lub treść artykułu ..."
searchIn = "Szukaj w {{ . }}"
+1
View File
@@ -89,6 +89,7 @@ valinePlaceholder = "O seu comentário..."
# === Assets ===
[assets]
table = "Tabela"
search = "Pesquisa"
searchPlaceholder = "Pesquisar títulos ou conteúdos..."
searchIn = "Pesquisar em {{ . }}"
+1
View File
@@ -88,6 +88,7 @@ valinePlaceholder = "Comentariul dvs ..."
# === Assets ===
[assets]
table = "Tabelă"
search = "Căutare"
searchPlaceholder = "Căutarea titlului sau conținutului articolului ..."
searchIn = "Căutare în {{ . }}"
+1
View File
@@ -88,6 +88,7 @@ valinePlaceholder = "Ваш комментарий ..."
# === Assets ===
[assets]
table = "Таблица"
search = "Поиск"
searchPlaceholder = "Поиск заголовков или содержимого ..."
searchIn = "Поиск в {{ . }}"
+1
View File
@@ -88,6 +88,7 @@ valinePlaceholder = "Ваш коментар ..."
# === Assets ===
[assets]
table = "Табела"
search = "Претрага"
searchPlaceholder = "Претражи наслове или садржај..."
searchIn = "Претражи у {{ . }}"
+1
View File
@@ -89,6 +89,7 @@ valinePlaceholder = "آپ کا تبصرہ ..."
# === Assets ===
[assets]
table = "جدول"
search = "تلاش"
searchPlaceholder = "عنوان یا مواد میں تلاش کریں ..."
searchIn = "{{ . }} میں تلاش کریں"
+1
View File
@@ -87,6 +87,7 @@ valinePlaceholder = "Bình luận của bạn ..."
# === Assets ===
[assets]
table = "Bảng"
search = "Tìm kiếm"
searchPlaceholder = "Tìm tiêu đề hoặc nội dung..."
searchIn = "Tìm kiếm trong {{ . }}"
+1
View File
@@ -86,6 +86,7 @@ valinePlaceholder = "你的评论……"
# === Assets ===
[assets]
table = "表"
search = "搜索"
searchPlaceholder = "搜索文章标题或内容……"
searchIn = "在 {{ . }} 中搜索"
+1
View File
@@ -86,6 +86,7 @@ valinePlaceholder = "你的評論……"
# === Assets ===
[assets]
table = "表"
search = "搜尋"
searchPlaceholder = "搜尋文章標題或內容……"
searchIn = "在 {{ . }} 中搜尋"
+33 -8
View File
@@ -1,15 +1,40 @@
{{- /*
Add a table wrapper to better style overflow tables
The reset of the template is the same as the default render-table.html
Custom table render hook for FixIt theme.
- Wraps tables in a scrollable container
- Supports auto-numbering via CSS counters (config: params.table.number)
- Supports client-side sorting (config: params.table.sort)
- Supports table captions via Markdown attribute {caption="..."}
- Per-table override via Markdown attributes (e.g., {number=false, sort=false})
See https://gohugo.io/render-hooks/tables/
*/ -}}
{{- $pageConfig := dict "Page" .Page "Key" "table" | partial "function/param.html" | merge .Attributes -}}
{{- $config := .Attributes | merge $pageConfig -}}
{{- $caption := .Attributes.caption -}}
{{- $attrs := "" -}}
{{- if $config.number }}
{{- $attrs = printf "%s data-table-numbered" $attrs }}
{{- end -}}
{{- if $config.sort }}
{{- $attrs = printf "%s data-sortable" $attrs }}
{{- .Page.Store.Set "hasTableSort" true }}
{{- end -}}
{{- range $k, $v := .Attributes }}
{{- if and $v (ne $k "number") (ne $k "caption") (ne $k "sort") }}
{{- $attrs = printf "%s %s=%q" $attrs $k $v }}
{{- end }}
{{- end -}}
{{- $attrs = partial "function/trim.html" $attrs -}}
<div class="table-wrapper">
<table
{{- range $k, $v := .Attributes }}
{{- if $v }}
{{- printf " %s=%q" $k $v | safeHTMLAttr }}
{{- end }}
{{- end }}>
<table {{ $attrs | safeHTMLAttr }}>
{{- if or $config.number $caption }}
<caption>
{{- if $config.number }}<span class="table-number" data-i18n-table="{{ T `assets.table` }}"></span>{{ end -}}
{{- with $caption }}<span class="table-caption">{{ . }}</span>{{ end -}}
</caption>
{{- end -}}
<thead>
{{- range .THead }}
<tr>
+5
View File
@@ -267,6 +267,11 @@
{{- dict "Source" "js/lib/watermark.ts" "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}}
{{- end -}}
{{- /* Table Sort */ -}}
{{- if .Store.Get "hasTableSort" -}}
{{- dict "Source" "js/lib/table-sort.ts" "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}}
{{- end -}}
{{- /* Content Decryption */ -}}
{{- $encryptPartial := .Store.Get "hasEncryptor" -}}
{{- if .Params.password | or $encryptPartial -}}