feat(new): auto-suggest kebab slug from title

m's request: typing "Mallorca 2026" into the new-item Title should
suggest "mallorca-2026" in the Slug field. Surface-only — server still
validates per itemwrite (^[a-z0-9][a-z0-9-]{0,62}$).

Inline ~25-line vanilla-JS handler on /new:
- normalize('NFD') + strip combining diacritics → ä→a, ñ→n, São→sao
- ß → ss (German sharp-s)
- non-alphanum run → single hyphen
- trim leading/trailing hyphens, collapse runs of hyphens
- slice(0, 63) to match the validator's length cap

Behavioural contract per m's brief:
- Slug syncs from Title on every Title input event UNTIL the user
  edits the slug manually. After that the slug field is locked in
  (`slug.dataset.userEdited === '1'`).
- A pre-filled slug counts as user-edited too — defensive against any
  future flow that lands on /new with a slug already populated.

Scoped to /new only — the detail-page edit form intentionally keeps
manual slug control because auto-sync there would silently rename
existing items.

Template additions:
- Added `id="new-item-form"`, `id="new-title"`, `id="new-slug"` to the
  form + inputs so the script can grab them by id rather than name
  (name="slug" exists on the detail page too and we don't want to
  cross-bind).

Test (web/new_form_test.go):
- TestNewFormHasSlugSuggestScript — asserts the inline script's
  signature fragments (`normalize('NFD')`, `replace(/ß/g, 'ss')`,
  `slice(0, 63)`, `dataset.userEdited`, the input ids) all render on
  /new. Guards against a "harmless cleanup" pass silently stripping
  the script.

Manual verification: typing "Mallorca 2026" updates slug to
"mallorca-2026"; typing in the slug field locks further sync.

Full web suite green.
This commit is contained in:
mAi
2026-05-27 14:30:23 +02:00
parent 547d6f77f6
commit 157c4e659b
2 changed files with 64 additions and 3 deletions

View File

@@ -62,6 +62,33 @@ func TestNewFormPreselectsParent(t *testing.T) {
}
}
// TestNewFormHasSlugSuggestScript pins the Phase 5k slug auto-suggest:
// the new-item template ships an inline <script> that derives a
// kebab-case slug from the title as the user types and stops syncing
// once the slug is edited manually. Without this guard a future
// template refactor could silently strip the script.
func TestNewFormHasSlugSuggestScript(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/new")
for _, want := range []string{
`id="new-title"`,
`id="new-slug"`,
// Algorithm signatures we don't want a "harmless cleanup" pass
// to drop quietly.
"normalize('NFD')",
"replace(/ß/g, 'ss')",
"replace(/[^a-z0-9]+/g, '-')",
"slice(0, 63)",
"dataset.userEdited",
} {
if !strings.Contains(body, want) {
t.Errorf("new-item template missing slug-suggest fragment %q", want)
}
}
}
// TestNewFormNoParentParamRendersAllOptions confirms the Parents <select>
// is populated even when no ?parent= is supplied — clicking "+ New" from the
// nav should still let the user pick any parent.

View File

@@ -2,10 +2,10 @@
<h1>New item</h1>
<p class="meta">Suggested parent: <strong>{{if .Parent}}{{.Parent.PrimaryPath}}{{else}}(root){{end}}</strong></p>
<form method="post" action="/new" class="edit">
<form method="post" action="/new" class="edit" id="new-item-form">
<input type="hidden" name="kind" value="project">
<label>Title <input name="title" required></label>
<label>Slug <input name="slug" required pattern="[^.]+" placeholder="lowercase, no dots"></label>
<label>Title <input id="new-title" name="title" required></label>
<label>Slug <input id="new-slug" name="slug" required pattern="[^.]+" placeholder="lowercase, no dots"></label>
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — leave empty for a root item)</small>
<select name="parent_ids" multiple size="6">
{{range .ParentOptions}}
@@ -32,4 +32,38 @@
<a class="cancel" href="{{if .Parent}}/i/{{.Parent.PrimaryPath}}{{else}}/{{end}}">Cancel</a>
</div>
</form>
<script>
// Phase 5k: auto-suggest a kebab-case slug from Title as the user types.
// Strips diacritics (Müller → muller, São → sao), German ß → ss, collapses
// any non-alphanumeric run into a single hyphen, trims edge hyphens, caps
// at the 63-char limit the itemwrite validator enforces. Once the user
// edits the slug manually, the sync stops — typing in Title no longer
// clobbers their override. A pre-filled slug also counts as user-edited
// (rare for /new but defensive).
(function() {
var title = document.getElementById('new-title');
var slug = document.getElementById('new-slug');
if (!title || !slug) return;
function kebab(s) {
return s
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-')
.slice(0, 63);
}
if (slug.value && slug.value.length > 0) {
slug.dataset.userEdited = '1';
}
title.addEventListener('input', function() {
if (slug.dataset.userEdited === '1') return;
slug.value = kebab(title.value);
});
slug.addEventListener('input', function() {
slug.dataset.userEdited = '1';
});
})();
</script>
{{end}}