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:
@@ -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.
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user