Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ All notable changes to OpenSignup are documented here. Format follows [Keep a Ch
- SMTP sends reuse their connections (#333). nodemailer used to connect, set up TLS and log in for every email, so a busy reminder run opened one connection per reminder, which was slower and could hit a mail provider's connections-per-minute limit (a `421` reply, then a retried job). The SMTP transport now keeps a pool of up to 3 connections open between emails, each replaced after 100 messages; the web server and the worker each keep their own. A connection the mail server closes while idle is dropped, and the next email opens a new one. An email whose connection closes while it is being sent is tried once more on another connection, then fails as before. The connect timeouts, the STARTTLS requirement and the refusal of file paths and URLs as content apply to every pooled connection as they did to each single one. Console and Resend are unchanged.

### Fixed
- A new signup's web address, and the ref of a new field or slot, now spell out letters that have no accent to strip (#641). ø and æ, œ, ß, ł, đ, þ and ð were dropped or split the word, so "Smørrebrød" became `sm-rrebr-d`, "Æblekage" `blekage` and "Straße" `stra-e`. They now read `smorrebrod`, `aeblekage` and `strasse`. Existing links and refs don't change. Titles in other scripts, such as Cyrillic or Chinese, still fall back to `signup`, `field` or `slot`.
- A signup can no longer be created or renamed with a title that is only spaces (#425). The title is trimmed before its 2-character minimum is checked, so " " is refused and " Bake sale " is saved as "Bake sale", on the New signup form, through `POST /api/signups` and `PATCH /api/signups/[id]`, and through the AI connector's `create_signup` and `update_signup`. The 120-character limit still counts the title as sent. Before, a create saved it as an empty title, and a rename saved the spaces as they were. A title that is too short now comes back with "Title must be at least 2 characters.", the Build tab's words, on the form, from the API and to a connected assistant, rather than "String must contain at least 2 character(s)". A Magic Compose draft whose title is blank is refused as other drafts that don't fit are, rather than saved with no title.
- A keyboard-focused Build, Responses or Settings tab on a signup's page now shows its whole focus ring (#586). The tab row scrolls sideways on a narrow screen, and that cut off the ring's top and bottom edges, and on a computer the Build tab's left edge too, so a keyboard user saw two short lines instead of a box. The ring is now drawn just inside each tab. Nothing else about the tabs changes.
- A form button no longer grows taller, or lifts its spinner above the middle, while it waits for an answer (#636). Save on a signup's Settings tab went from 36 to 39px while it said "Saving…", and smaller buttons grew more, so the button jumped and snapped back. The spinner and word inside `AsyncSubmitButton` now sit in a block-level flex box, so they're centred and the button keeps its height. This covers every button that shows a spinner while it waits: Save and "Yes, delete signup" on Settings, "Yes, remove" on Responses, Create signup, Publish and Close signup, Disconnect on Connected apps, and Allow and Don't allow on the consent screen.
Expand Down
46 changes: 46 additions & 0 deletions src/lib/kebab.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { toKebab } from './kebab';

describe('toKebab', () => {
it('spells out letters that have no accent to strip', () => {
expect(toKebab('Smørrebrød')).toBe('smorrebrod');
expect(toKebab('Æblekage')).toBe('aeblekage');
expect(toKebab('Straße')).toBe('strasse');
expect(toKebab('Łódź')).toBe('lodz');
});

it.each([
['ø', 'o'],
['Ø', 'o'],
['æ', 'ae'],
['Æ', 'ae'],
['œ', 'oe'],
['Œ', 'oe'],
['ß', 'ss'],
['ẞ', 'ss'],
['ł', 'l'],
['Ł', 'l'],
['đ', 'd'],
['Đ', 'd'],
['þ', 'th'],
['Þ', 'th'],
['ð', 'd'],
['Ð', 'd'],
])('maps %s to %s', (letter, spelled) => {
expect(toKebab(`a${letter}b`)).toBe(`a${spelled}b`);
});

it('maps a mapped letter that also carries an accent', () => {
expect(toKebab('ǿ')).toBe('o');
});

it('strips accents from letters that decompose, as before', () => {
expect(toKebab('Åsa och Märta på Öland')).toBe('asa-och-marta-pa-oland');
expect(toKebab('Crèche rota — Été / Hiver')).toBe('creche-rota-ete-hiver');
});

it('leaves scripts it cannot spell in Latin letters empty', () => {
expect(toKebab('Волонтёры')).toBe('');
expect(toKebab('志愿者')).toBe('');
});
});
27 changes: 27 additions & 0 deletions src/lib/kebab.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
/**
* Latin letters NFKD leaves whole, so stripping accents can't reach them.
* Without this, "Smørrebrød" came out as `sm-rrebr-d`.
*/
const SPELLED: Readonly<Record<string, string>> = {
ø: 'o',
Ø: 'O',
æ: 'ae',
Æ: 'AE',
œ: 'oe',
Œ: 'OE',
ß: 'ss',
ẞ: 'SS',
ł: 'l',
Ł: 'L',
đ: 'd',
Đ: 'D',
þ: 'th',
Þ: 'TH',
ð: 'd',
Ð: 'D',
};
const SPELLED_LETTER = new RegExp(`[${Object.keys(SPELLED).join('')}]`, 'g');

/**
* Lowercase kebab-case with accents stripped: the text rule behind signup
* slugs and field refs. No Node imports, so the builder can use it too.
* Other scripts (Cyrillic, Greek, Chinese) come out empty and callers fall
* back to a fixed word.
*/
export function toKebab(input: string): string {
return input
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(SPELLED_LETTER, (letter) => SPELLED[letter] ?? letter)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
Expand Down
Loading