Skip to content

Commit 602c10f

Browse files
committed
fix: make git-derived last-updated dates reliable and localize their display
1 parent 9aacaf8 commit 602c10f

18 files changed

Lines changed: 746 additions & 34 deletions

docs/content/docs/reference/configuration.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,17 @@ social:
8282

8383
### Date format
8484

85-
`theme.date_format` accepts three names or any [Go layout string](https://pkg.go.dev/time#pkg-constants):
85+
`theme.date_format` accepts three preset names or any [Go layout string](https://pkg.go.dev/time#pkg-constants):
8686

87-
| Value | Renders as |
87+
| Value | Renders as (English) |
8888
|-------|-----------|
8989
| `short` (default) | Jan 2, 2006 |
9090
| `long` | January 2, 2006 |
9191
| `iso` | 2006-01-02 |
9292
| Any other value | Used verbatim as a Go layout, e.g. `2006/01/02` |
9393

94+
The `short` and `long` presets are **locale-aware**: on a multilingual site, each page renders the date in its own language using CLDR data, so a French page shows "1 juin 2025" while the English page shows "Jun 1, 2025". Around 30 common languages are supported out of the box; a language without built-in data falls back to the English format. `iso` is locale-independent, and a custom Go layout always renders English month names.
95+
9496
This controls **only** the "last updated" date rendered by the [LastUpdated component](/reference/ui-components#lastupdated). Other dates in the theme, such as blog post dates and list-page dates, use formats fixed by their templates; override those templates to change them.
9597

9698
The `datetime` attribute on the emitted `<time>` element is always ISO 8601 regardless of this setting, so the markup stays machine-readable. To change where the timestamp comes from rather than how it looks, see [`build.last_updated`](#last-updated-strategy).

docs/content/docs/reference/ui-components.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ Called from the docs, labs, blog single, and default single layouts, inside the
255255

256256
The date is rendered at build time, so it is present without JavaScript and the page does not shift on load.
257257

258-
The display format comes from [`theme.date_format`](/reference/configuration#theme), which accepts `short`, `long`, `iso`, or any Go layout string. The `datetime` attribute is always ISO 8601 regardless, so the markup stays machine-readable. The timestamp itself is resolved by [`build.last_updated`](/reference/configuration#last-updated-strategy).
258+
The display format comes from [`theme.date_format`](/reference/configuration#theme), which accepts `short`, `long`, `iso`, or any Go layout string. The `short` and `long` presets are locale-aware: each page renders the date in its own language (CLDR data for about 30 common languages, English fallback otherwise), while custom Go layouts always render English. The `datetime` attribute is always ISO 8601 regardless, so the markup stays machine-readable. The timestamp itself is resolved by [`build.last_updated`](/reference/configuration#last-updated-strategy).
259259

260260
### Page meta row
261261

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ require (
1515
github.com/fsnotify/fsnotify v1.9.0
1616
github.com/gen2brain/avif v0.4.4
1717
github.com/gen2brain/webp v0.5.5
18+
github.com/go-playground/locales v0.14.1
1819
github.com/gorilla/websocket v1.5.3
1920
github.com/pelletier/go-toml/v2 v2.3.0
2021
github.com/spf13/cobra v1.10.2

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg=
4545
github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0=
4646
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
4747
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
48+
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
49+
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
4850
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
4951
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
5052
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=

internal/build/gitindex.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"github.com/getsarde/sarde/internal/consts"
77
"github.com/getsarde/sarde/internal/content"
8+
"github.com/getsarde/sarde/internal/devlog"
89
"github.com/getsarde/sarde/internal/engine"
910
)
1011

@@ -50,3 +51,43 @@ func (b *SiteBuilder) resolveGitIndex(files []content.ContentFile, contentDir st
5051

5152
return idx, nil
5253
}
54+
55+
// refreshGitIndexIfStale rebuilds b.lastGitIndex when HEAD moved since it was
56+
// captured (a commit landed from another terminal while the dev server was
57+
// running), so incremental rebuilds resolve fresh commit dates instead of
58+
// dates frozen at the last full build's HEAD.
59+
//
60+
// Runs once per ContentRebuild call, before any file in the batch is parsed.
61+
// It rebuilds against every page path known from the last build, not just this
62+
// batch's files: later rebuilds reuse the same index, and a narrower list
63+
// would drop entries for every other page.
64+
//
65+
// A rebuild failure keeps the previous index in place; a mid-session dev
66+
// server must not lose dates it already resolved over a transient git
67+
// problem, so the failure is only logged.
68+
func (b *SiteBuilder) refreshGitIndexIfStale() {
69+
if !b.lastGitIndex.Available() || !b.lastGitIndex.Stale() {
70+
return
71+
}
72+
73+
seen := make(map[string]struct{}, len(b.lastAllPages))
74+
paths := make([]string, 0, len(b.lastAllPages))
75+
for _, p := range b.lastAllPages {
76+
if p.FilePath == "" || p.IsFallback {
77+
continue
78+
}
79+
if _, ok := seen[p.FilePath]; ok {
80+
continue
81+
}
82+
seen[p.FilePath] = struct{}{}
83+
paths = append(paths, p.FilePath)
84+
}
85+
86+
idx, err := content.BuildGitLastModIndex(b.resolveContentDir(), paths)
87+
if err != nil {
88+
devlog.Warn("build", "ContentRebuild: git index refresh failed after HEAD moved, keeping previous dates: %v", err)
89+
return
90+
}
91+
devlog.Log("build", "ContentRebuild: refreshed git index for %d path(s) (HEAD moved)", len(paths))
92+
b.lastGitIndex = idx
93+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package build
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
"time"
10+
11+
"github.com/getsarde/sarde/embedded"
12+
"github.com/getsarde/sarde/internal/config"
13+
"github.com/getsarde/sarde/internal/engine"
14+
)
15+
16+
// initGitTestRepo creates a git repository with local identity config so
17+
// commits work regardless of the machine's global git setup.
18+
func initGitTestRepo(t *testing.T) string {
19+
t.Helper()
20+
if _, err := exec.LookPath("git"); err != nil {
21+
t.Skip("git not installed")
22+
}
23+
dir := t.TempDir()
24+
gitRunT(t, dir, "init", "-q")
25+
gitRunT(t, dir, "config", "user.email", "test@example.com")
26+
gitRunT(t, dir, "config", "user.name", "Test")
27+
gitRunT(t, dir, "config", "commit.gpgsign", "false")
28+
return dir
29+
}
30+
31+
func gitRunT(t *testing.T, dir string, args ...string) {
32+
t.Helper()
33+
cmd := exec.Command("git", args...)
34+
cmd.Dir = dir
35+
if out, err := cmd.CombinedOutput(); err != nil {
36+
t.Fatalf("git %v: %v\n%s", args, err, out)
37+
}
38+
}
39+
40+
// commitGitAt commits everything staged with a fixed author and committer date.
41+
func commitGitAt(t *testing.T, dir, message string, when time.Time) {
42+
t.Helper()
43+
stamp := when.Format(time.RFC3339)
44+
cmd := exec.Command("git", "commit", "-q", "-m", message, "--date", stamp)
45+
cmd.Dir = dir
46+
cmd.Env = append(os.Environ(), "GIT_COMMITTER_DATE="+stamp)
47+
if out, err := cmd.CombinedOutput(); err != nil {
48+
t.Fatalf("git commit: %v\n%s", err, out)
49+
}
50+
}
51+
52+
// TestContentRebuild_RefreshesStaleGitIndex guards the dev-server git-date
53+
// freeze: a commit landing from outside the dev server (another terminal)
54+
// must be picked up by the next ContentRebuild, not served from the git index
55+
// snapshot captured at the last full build's HEAD.
56+
func TestContentRebuild_RefreshesStaleGitIndex(t *testing.T) {
57+
repo := initGitTestRepo(t)
58+
t1 := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC)
59+
t2 := time.Date(2026, 3, 15, 14, 30, 0, 0, time.UTC)
60+
61+
writeFixture(t, repo, "content/_index.md", "---\ntitle: Home\n---\n# Home\n")
62+
writeFixture(t, repo, "content/docs/_index.md", "---\ntitle: Docs\n---\n")
63+
writeFixture(t, repo, "content/docs/guide.md", "---\ntitle: Guide\nweight: 1\n---\n# Guide\nOriginal body.\n")
64+
gitRunT(t, repo, "add", "-A")
65+
commitGitAt(t, repo, "initial", t1)
66+
67+
cfg := config.Defaults() // build.last_updated: git (embedded default)
68+
cfg.Build.Minify = config.BoolPtr(false)
69+
builder := NewSiteBuilder(BuildOptions{
70+
ProjectDir: repo,
71+
Config: cfg,
72+
ThemeConfig: buildThemeConfig(),
73+
EmbeddedFS: embedded.ThemeFS(),
74+
})
75+
if _, err := builder.Build(); err != nil {
76+
t.Fatalf("full build failed: %v", err)
77+
}
78+
if !builder.lastGitIndex.Available() {
79+
t.Fatal("expected an available git index after the full build")
80+
}
81+
82+
guidePath := filepath.Join(repo, "content", "docs", "guide.md")
83+
if got, ok := builder.lastGitIndex.Lookup(guidePath); !ok || !got.Equal(t1) {
84+
t.Fatalf("index after full build: got %v (ok=%v), want %v", got, ok, t1)
85+
}
86+
87+
// Simulate an edit plus commit from outside the dev server.
88+
writeFixture(t, repo, "content/docs/guide.md", "---\ntitle: Guide\nweight: 1\n---\n# Guide\nUpdated body.\n")
89+
gitRunT(t, repo, "add", "content/docs/guide.md")
90+
commitGitAt(t, repo, "external edit", t2)
91+
92+
if !builder.lastGitIndex.Stale() {
93+
t.Fatal("index should report stale once HEAD moves past the full build's snapshot")
94+
}
95+
96+
result, err := builder.ContentRebuild([]string{guidePath})
97+
if err != nil {
98+
t.Fatalf("ContentRebuild failed: %v", err)
99+
}
100+
if result.PageCount < 1 {
101+
t.Errorf("PageCount = %d, want at least the rebuilt page", result.PageCount)
102+
}
103+
104+
if builder.lastGitIndex.Stale() {
105+
t.Error("index should be fresh immediately after ContentRebuild")
106+
}
107+
if got, ok := builder.lastGitIndex.Lookup(guidePath); !ok || !got.Equal(t2) {
108+
t.Errorf("index after ContentRebuild: got %v (ok=%v), want %v", got, ok, t2)
109+
}
110+
111+
var page *engine.Page
112+
for _, p := range builder.lastAllPages {
113+
if p.FilePath == guidePath {
114+
page = p
115+
break
116+
}
117+
}
118+
if page == nil {
119+
t.Fatal("rebuilt guide page missing from lastAllPages")
120+
}
121+
if !page.Updated.Equal(t2) {
122+
t.Errorf("page.Updated = %v, want %v (new commit time)", page.Updated, t2)
123+
}
124+
125+
data, err := os.ReadFile(filepath.Join(repo, "dist", "docs", "guide", "index.html"))
126+
if err != nil {
127+
t.Fatalf("reading rebuilt output: %v", err)
128+
}
129+
wantDatetime := `<time datetime="` + t2.Format("2006-01-02") + `"`
130+
if !strings.Contains(string(data), wantDatetime) {
131+
t.Errorf("rendered output missing %q", wantDatetime)
132+
}
133+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package build
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/getsarde/sarde/embedded"
10+
"github.com/getsarde/sarde/internal/config"
11+
)
12+
13+
// createPageMetaFixtureSite creates a docs site with one page carrying an
14+
// explicit `updated` frontmatter date, so the rendered "last updated" text is
15+
// deterministic regardless of the last_updated strategy or git availability.
16+
func createPageMetaFixtureSite(t *testing.T) string {
17+
t.Helper()
18+
dir := t.TempDir()
19+
writeFixture(t, dir, "content/_index.md", "---\ntitle: Home\n---\n# Home\n")
20+
writeFixture(t, dir, "content/docs/_index.md", "---\ntitle: Docs\n---\n")
21+
writeFixture(t, dir, "content/docs/guide.md", "---\ntitle: Guide\nweight: 1\nupdated: 2025-06-01T00:00:00Z\n---\n# Guide\nBody.\n")
22+
return dir
23+
}
24+
25+
// buildPageMetaSite builds the fixture with the given theme.date_format and
26+
// returns the rendered docs/guide page. The raw preset name is passed
27+
// through: since the locale-aware date work, presets are resolved at format
28+
// time by the dateFormat template function, not at config load.
29+
func buildPageMetaSite(t *testing.T, dateFormat string) string {
30+
t.Helper()
31+
dir := createPageMetaFixtureSite(t)
32+
cfg := config.Defaults()
33+
cfg.Build.Minify = config.BoolPtr(false)
34+
if dateFormat != "" {
35+
cfg.Theme.DateFormat = dateFormat
36+
}
37+
38+
builder := NewSiteBuilder(BuildOptions{
39+
ProjectDir: dir,
40+
Config: cfg,
41+
ThemeConfig: buildThemeConfig(),
42+
EmbeddedFS: embedded.ThemeFS(),
43+
})
44+
if _, err := builder.Build(); err != nil {
45+
t.Fatalf("Build failed: %v", err)
46+
}
47+
data, err := os.ReadFile(filepath.Join(dir, "dist", "docs", "guide", "index.html"))
48+
if err != nil {
49+
t.Fatalf("reading rendered guide page: %v", err)
50+
}
51+
return string(data)
52+
}
53+
54+
// TestBuild_PageMeta_LastUpdatedRendersOnDocsPage covers the meta row that
55+
// groups the edit link and the last-updated date: the sarde-page-meta
56+
// wrapper, the machine-readable datetime attribute, and the i18n label all
57+
// render on a docs single page.
58+
func TestBuild_PageMeta_LastUpdatedRendersOnDocsPage(t *testing.T) {
59+
html := buildPageMetaSite(t, "") // default "short" preset
60+
61+
if !strings.Contains(html, `<div class="sarde-page-meta">`) {
62+
t.Error("expected the sarde-page-meta wrapper around EditLink/LastUpdated")
63+
}
64+
if !strings.Contains(html, `<time datetime="2025-06-01">`) {
65+
t.Error("expected a machine-readable datetime attribute for the updated date")
66+
}
67+
if !strings.Contains(html, "Last updated") {
68+
t.Error("expected the i18n last_updated_label text")
69+
}
70+
if !strings.Contains(html, "Jun 1, 2025") {
71+
t.Error("expected the short-format visible date")
72+
}
73+
}
74+
75+
// TestBuild_PageMeta_DateFormatLongChangesDisplay covers theme.date_format
76+
// actually reaching the rendered output, not just NormalizeDateFormat's own
77+
// unit tests in internal/config.
78+
func TestBuild_PageMeta_DateFormatLongChangesDisplay(t *testing.T) {
79+
html := buildPageMetaSite(t, "long")
80+
81+
if !strings.Contains(html, "June 1, 2025") {
82+
t.Error("theme.date_format: long should render the long month name")
83+
}
84+
if strings.Contains(html, "Jun 1, 2025") {
85+
t.Error("theme.date_format: long should not render the short-format date")
86+
}
87+
}
88+
89+
// TestBuild_PageMeta_FrenchPageLocalizesDate covers the locale-aware presets
90+
// end to end on a multilingual site: the same theme.date_format preset
91+
// renders CLDR French on the fr page while the en page keeps the old English
92+
// output byte for byte.
93+
func TestBuild_PageMeta_FrenchPageLocalizesDate(t *testing.T) {
94+
dir := t.TempDir()
95+
page := "---\ntitle: Guide\nweight: 1\nupdated: 2025-06-01T00:00:00Z\n---\n# Guide\nBody.\n"
96+
writeFixture(t, dir, "content/_index.md", "---\ntitle: Home\n---\n# Home\n")
97+
writeFixture(t, dir, "content/docs/_index.md", "---\ntitle: Docs\n---\n")
98+
writeFixture(t, dir, "content/docs/guide.md", page)
99+
writeFixture(t, dir, "content/fr/_index.md", "---\ntitle: Accueil\n---\n# Accueil\n")
100+
writeFixture(t, dir, "content/fr/docs/_index.md", "---\ntitle: Docs\n---\n")
101+
writeFixture(t, dir, "content/fr/docs/guide.md", page)
102+
103+
cfg := config.Defaults()
104+
cfg.Build.Minify = config.BoolPtr(false)
105+
cfg.I18n.DefaultLanguage = "en"
106+
cfg.I18n.Languages = map[string]config.LanguageConfig{
107+
"en": {Name: "English", Weight: 1, Dir: "ltr"},
108+
"fr": {Name: "French", Weight: 2, Dir: "ltr"},
109+
}
110+
111+
builder := NewSiteBuilder(BuildOptions{
112+
ProjectDir: dir,
113+
Config: cfg,
114+
ThemeConfig: buildThemeConfig(),
115+
EmbeddedFS: embedded.ThemeFS(),
116+
})
117+
if _, err := builder.Build(); err != nil {
118+
t.Fatalf("Build failed: %v", err)
119+
}
120+
121+
read := func(rel string) string {
122+
t.Helper()
123+
data, err := os.ReadFile(filepath.Join(dir, "dist", filepath.FromSlash(rel)))
124+
if err != nil {
125+
t.Fatalf("reading %s: %v", rel, err)
126+
}
127+
return string(data)
128+
}
129+
130+
enHTML := read("docs/guide/index.html")
131+
if !strings.Contains(enHTML, "Jun 1, 2025") {
132+
t.Error("en page should keep the English short date")
133+
}
134+
135+
frHTML := read("fr/docs/guide/index.html")
136+
if !strings.Contains(frHTML, "1 juin 2025") {
137+
t.Error("fr page should render the CLDR French date")
138+
}
139+
if strings.Contains(frHTML, "Jun 1, 2025") {
140+
t.Error("fr page must not render the English date")
141+
}
142+
if !strings.Contains(frHTML, `datetime="2025-06-01"`) {
143+
t.Error("fr page must keep the ISO datetime attribute")
144+
}
145+
}

internal/build/rebuild.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ func (b *SiteBuilder) ContentRebuild(changedPaths []string) (*engine.BuildResult
100100
phaseStart = time.Now()
101101
}
102102

103+
// Commit dates otherwise freeze at the HEAD of the last full build for the
104+
// rest of the dev session.
105+
b.refreshGitIndexIfStale()
106+
103107
if err := b.classifyAndParseChanges(changedPaths, s); err != nil {
104108
return b.rebuildFallback(err)
105109
}

0 commit comments

Comments
 (0)