From 1858773fcaaf1e11cb4cdb02be9dee89b1bd9dd6 Mon Sep 17 00:00:00 2001 From: kamijo-haruto Date: Tue, 28 Jul 2026 15:36:00 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[feat]=20QR=E3=82=B3=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=81=AE=E4=B8=8B=E3=81=AB=E5=85=A5=E3=82=8C=E3=82=8B=E6=96=87?= =?UTF-8?q?=E5=AD=97=E3=82=92=E4=BB=BB=E6=84=8F=E3=81=AB=E3=81=97=E3=80=81?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E5=90=8D=E3=82=92=E8=AD=98?= =?UTF-8?q?=E5=88=A5=E5=90=8D=5FQR=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 必須2項目(名前・媒体)それぞれにチェックボックスを設け、どちらも既定はオフに する。既定を「入れない」にしたのは、ポスターのように自前のデザインを持つものに とっては下の文字が切り落とす手間でしかないため。チェックボックスには実際に印字 される文字列を並記する。「媒体」とだけ書いてあっても、それが場所を伴って 「ポスター · 本館1F」と出ることは伝わらないため。 場所は任意項目なので独立したトグルを設けず、媒体と同じ行のまま媒体のチェックに 連動させる。媒体抜きで場所だけが印字されると紙の上で意味の取れない断片になる。 太字の判定を行の位置ではなく項目に紐づける。従来は1行目を太字にしていたため、 名前をオフにすると媒体が太字になって見出しのように見えていた。 ダウンロードするPNGのファイル名から媒体と場所を落とし、識別名_QR.png にする。 この2つはコード間で重複しがちで、フォルダ内で実際に探す手がかりになる名前が 埋もれていた。'QR' は文字列連結ではなく slugForFilename の引数として渡し、 名前が空でも _QR.png という壊れた見た目にならないようにする。 選択は safeStorage に保存して次回以降も引き継ぐ。QRコードは1枚ずつダウンロード するが印刷はまとめて行うので、20枚分チェックし直すのが作業の大半になるため。 初回は QR_CAPTION_DEFAULTS のままで、キャプションは付かない。 Co-Authored-By: Claude Opus 5 (1M context) --- packages/web/src/lib/i18n.tsx | 8 ++ packages/web/src/lib/qr.test.ts | 102 ++++++++++++++++- packages/web/src/lib/qr.ts | 99 +++++++++++++++-- packages/web/src/pages/QRCodesPage.tsx | 145 ++++++++++++++++++++++--- 4 files changed, 327 insertions(+), 27 deletions(-) diff --git a/packages/web/src/lib/i18n.tsx b/packages/web/src/lib/i18n.tsx index bde9dc7..6b91f68 100644 --- a/packages/web/src/lib/i18n.tsx +++ b/packages/web/src/lib/i18n.tsx @@ -170,6 +170,10 @@ const en: Dictionary = { 'qrCodes.scanUrlLabel': 'This code links to', 'qrCodes.generateFailed': 'Failed to generate the QR code', 'qrCodes.downloadButton': 'Download PNG', + 'qrCodes.captionLegend': 'Text printed under the code', + 'qrCodes.captionHint': 'Printed under the code in the downloaded PNG.', + 'qrCodes.captionHintNone': + 'The downloaded PNG will contain the code only, with no text.', 'csvExport.downloadFailed': 'Failed to download the CSV', 'csvExport.disabled': 'CSV export is not enabled yet', @@ -332,6 +336,10 @@ const ja: Dictionary = { 'qrCodes.scanUrlLabel': 'このコードの転送先', 'qrCodes.generateFailed': 'QRコードの生成に失敗しました', 'qrCodes.downloadButton': 'PNGをダウンロード', + 'qrCodes.captionLegend': 'QRコードの下に入れる文字', + 'qrCodes.captionHint': 'ダウンロードするPNGのQRコードの下に印字されます。', + 'qrCodes.captionHintNone': + 'ダウンロードするPNGにはQRコードだけが入り、文字は入りません。', 'csvExport.downloadFailed': 'CSVのダウンロードに失敗しました', 'csvExport.disabled': 'CSVダウンロード機能は現在無効です', diff --git a/packages/web/src/lib/qr.test.ts b/packages/web/src/lib/qr.test.ts index ba9c7ba..2460637 100644 --- a/packages/web/src/lib/qr.test.ts +++ b/packages/web/src/lib/qr.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { deriveFallbackKey, qrTargetUrl } from './qr'; +import { + QR_CAPTION_DEFAULTS, + deriveFallbackKey, + qrCaptionLines, + qrPngFileName, + qrTargetUrl, +} from './qr'; /** * These two functions decide what gets physically printed. A mistake here is not @@ -58,3 +64,97 @@ describe('qrTargetUrl', () => { expect(qrTargetUrl('abc', 'a b')).toContain('&p=a%20b'); }); }); + +describe('qrCaptionLines', () => { + const qr = { name: 'Poster A', medium: 'Poster', location: '1F' }; + + it('prints nothing by default', () => { + expect(QR_CAPTION_DEFAULTS).toEqual({ + includeName: false, + includeMedium: false, + }); + expect(qrCaptionLines(qr, QR_CAPTION_DEFAULTS)).toEqual([]); + }); + + it('prints only the name when only the name is selected', () => { + expect( + qrCaptionLines(qr, { includeName: true, includeMedium: false }), + ).toEqual([{ text: 'Poster A', emphasis: true }]); + }); + + it('carries the location along with the medium', () => { + expect( + qrCaptionLines(qr, { includeName: false, includeMedium: true }), + ).toEqual([{ text: 'Poster · 1F' }]); + }); + + // Emphasis follows the field, not the line's position — otherwise a + // medium-only caption would print bold and read as a title. + it('never emphasises the medium, even when it is the only line', () => { + const [line] = qrCaptionLines(qr, { + includeName: false, + includeMedium: true, + }); + expect(line?.emphasis).toBeUndefined(); + }); + + it('puts the name first when both are selected', () => { + expect( + qrCaptionLines(qr, { includeName: true, includeMedium: true }), + ).toEqual([{ text: 'Poster A', emphasis: true }, { text: 'Poster · 1F' }]); + }); + + it('omits the separator when there is no location', () => { + expect( + qrCaptionLines( + { name: 'A', medium: 'Poster' }, + { includeName: false, includeMedium: true }, + ), + ).toEqual([{ text: 'Poster' }]); + }); + + // location is optional and may come back as null from the API. + it('treats a null location as absent', () => { + expect( + qrCaptionLines( + { name: 'A', medium: 'Poster', location: null }, + { includeName: false, includeMedium: true }, + ), + ).toEqual([{ text: 'Poster' }]); + }); + + it('emits no line for a selected but empty field', () => { + expect( + qrCaptionLines( + { name: '', medium: '', location: '' }, + { includeName: true, includeMedium: true }, + ), + ).toEqual([]); + }); +}); + +describe('qrPngFileName', () => { + it('is "_QR.png"', () => { + expect(qrPngFileName('造形大ポスター')).toBe('造形大ポスター_QR.png'); + }); + + // The medium and location were dropped from the filename on purpose. + it('carries the name only', () => { + expect(qrPngFileName('看板A')).toBe('看板A_QR.png'); + }); + + it('collapses whitespace rather than leaving it in the path', () => { + expect(qrPngFileName('造形大 ポスター')).toBe('造形大_ポスター_QR.png'); + }); + + it('strips characters no filesystem accepts', () => { + expect(qrPngFileName('a/b:c*d')).toBe('abcd_QR.png'); + }); + + // name is required by the API, so this is defence rather than a real case — + // but a leading underscore would look like a broken filename. + it('does not leave a stray separator when the name is blank', () => { + expect(qrPngFileName('')).toBe('QR.png'); + expect(qrPngFileName('///')).toBe('QR.png'); + }); +}); diff --git a/packages/web/src/lib/qr.ts b/packages/web/src/lib/qr.ts index 3b25825..cb1b6ce 100644 --- a/packages/web/src/lib/qr.ts +++ b/packages/web/src/lib/qr.ts @@ -1,5 +1,6 @@ import QRCodeLib from 'qrcode'; import { TRACKING_LINK_API_URL } from '../config'; +import { slugForFilename } from './format'; /** * Base URL that scanned QR codes resolve against. Separate from the API URL @@ -65,6 +66,23 @@ export function qrTargetUrl(qrId: string, fallbackKey = ''): string { return fallbackKey ? `${base}&p=${encodeURIComponent(fallbackKey)}` : base; } +/** + * Filename for a downloaded QR code PNG: `_QR.png`. + * + * Only the name goes in. The medium and location were in here as well, which + * buried the one part anyone actually scans a folder for behind two fields that + * repeat across most codes. + * + * Extracted rather than inlined for the same reason as qrTargetUrl: a bulk-print + * view will need exactly this, and the two must not drift. + */ +export function qrPngFileName(name: string): string { + // 'QR' is passed as a part rather than appended, so the separator collapsing + // in slugForFilename applies to it too — a blank name gives "QR.png", not + // "_QR.png". + return `${slugForFilename(name, 'QR')}.png`; +} + /** On-screen preview. */ export function qrPreviewDataUrl(text: string): Promise { return QRCodeLib.toDataURL(text, { @@ -79,6 +97,64 @@ const PNG_PADDING = 32; const CAPTION_LINE_HEIGHT = 34; const CAPTION_FONT_SIZE = 24; +/** One printed line beneath the QR code. */ +export interface QrCaptionLine { + text: string; + /** Rendered bold. Reserved for the name — see qrCaptionLines. */ + emphasis?: boolean; +} + +/** Which of the two required fields get printed under the code. */ +export interface QrCaptionOptions { + includeName: boolean; + includeMedium: boolean; +} + +/** + * Nothing is printed unless asked for. + * + * A caption is only wanted when the sheet is one of many being sorted by hand; + * for a poster that already carries its own design, text under the code is + * clutter someone has to crop off. Defaulting to off makes the clean version the + * one you get without thinking about it. + */ +export const QR_CAPTION_DEFAULTS: QrCaptionOptions = { + includeName: false, + includeMedium: false, +}; + +/** + * Builds the caption for a QR code PNG from the two required fields. + * + * `location` is optional and has no toggle of its own: it qualifies the medium + * ("Poster · 1F bulletin board") and shares its line, so it follows the medium's + * checkbox. A location with no medium to attach to would read as a stray + * fragment on a printed sheet. + * + * Split out of the dialog so the mapping from checkboxes to printed lines can be + * tested without a canvas. + */ +export function qrCaptionLines( + qr: { name: string; medium: string; location?: string | null }, + options: QrCaptionOptions, +): QrCaptionLine[] { + const lines: QrCaptionLine[] = []; + + // The name is what someone reads from across a corridor, so it takes the bold + // weight — but only ever the name. Emphasis is tied to the field, not to the + // position, or a medium-only caption would print in bold and read as a title. + if (options.includeName && qr.name) { + lines.push({ text: qr.name, emphasis: true }); + } + + if (options.includeMedium) { + const text = [qr.medium, qr.location].filter(Boolean).join(' · '); + if (text) lines.push({ text }); + } + + return lines; +} + /** Shortens a caption line to fit the image width, with an ellipsis. */ function fitText( ctx: CanvasRenderingContext2D, @@ -100,16 +176,17 @@ function fitText( } /** - * Renders a QR code to a PNG with its name, medium and location printed beneath. + * Renders a QR code to a PNG, optionally with a caption printed beneath. * - * The caption is the point. One QR code is generated per physical item, and the - * downloaded PNG used to carry no identifying text at all — so once printed, - * nobody could tell which poster a given sheet belonged to. The filename helps in - * a folder; only the caption helps on paper. + * One QR code is generated per physical item, so on paper the caption is the only + * thing that says which item a given sheet belongs to — the filename only helps + * while it is still in a folder. It is opt-in nonetheless: see + * QR_CAPTION_DEFAULTS. With no lines the output is just the code on white, which + * is what a designer placing it into a poster wants. */ export async function qrPngBlob( text: string, - captionLines: string[], + captionLines: QrCaptionLine[] = [], ): Promise { const qrCanvas = document.createElement('canvas'); await QRCodeLib.toCanvas(qrCanvas, text, { @@ -118,7 +195,7 @@ export async function qrPngBlob( errorCorrectionLevel: 'H', }); - const lines = captionLines.filter(Boolean); + const lines = captionLines.filter((line) => line.text); const canvas = document.createElement('canvas'); canvas.width = qrCanvas.width + PNG_PADDING * 2; canvas.height = @@ -141,11 +218,9 @@ export async function qrPngBlob( ctx.textBaseline = 'top'; const maxWidth = canvas.width - PNG_PADDING * 2; let y = qrCanvas.height + PNG_PADDING + PNG_PADDING / 2; - for (const [index, line] of lines.entries()) { - // First line is the name, and it is what someone reads from across a - // corridor — so it gets the bold weight. - ctx.font = `${index === 0 ? '600 ' : ''}${CAPTION_FONT_SIZE}px system-ui, sans-serif`; - ctx.fillText(fitText(ctx, line, maxWidth), canvas.width / 2, y); + for (const line of lines) { + ctx.font = `${line.emphasis ? '600 ' : ''}${CAPTION_FONT_SIZE}px system-ui, sans-serif`; + ctx.fillText(fitText(ctx, line.text, maxWidth), canvas.width / 2, y); y += CAPTION_LINE_HEIGHT; } } diff --git a/packages/web/src/pages/QRCodesPage.tsx b/packages/web/src/pages/QRCodesPage.tsx index 7faac53..fc8e451 100644 --- a/packages/web/src/pages/QRCodesPage.tsx +++ b/packages/web/src/pages/QRCodesPage.tsx @@ -28,14 +28,19 @@ import { useListQuery } from '../hooks/useListQuery'; import { Permissions, hasPermission } from '../hooks/useStaffAuth'; import { ApiError, assertOk, authFetch } from '../lib/api'; import { downloadBlob } from '../lib/download'; -import { formatDateTime, slugForFilename } from '../lib/format'; +import { formatDateTime } from '../lib/format'; import { useTranslation } from '../lib/i18n'; import { + QR_CAPTION_DEFAULTS, + type QrCaptionOptions, deriveFallbackKey, + qrCaptionLines, qrPngBlob, + qrPngFileName, qrPreviewDataUrl, qrTargetUrl, } from '../lib/qr'; +import { safeStorage } from '../lib/storage'; import { btnPrimary, btnRow, @@ -123,6 +128,53 @@ function QRThumbnail({ ); } +const CAPTION_OPTIONS_KEY = 'tracking-link.qrCaption'; + +/** + * Remembers the caption checkboxes across dialogs. + * + * QR codes are downloaded one at a time but printed as a batch, so the choice is + * made once for a run of twenty and re-ticking two boxes per code would be the + * bulk of the work. Persisting only carries a deliberate choice forward — the + * first-run state is still QR_CAPTION_DEFAULTS, i.e. no caption. + * + * safeStorage rather than localStorage: this reads inside a useState initialiser, + * and iOS Safari in private browsing throws on the getter itself. + */ +function useCaptionOptions(): [ + QrCaptionOptions, + (next: QrCaptionOptions) => void, +] { + const [options, setOptions] = useState(() => { + const raw = safeStorage.get(CAPTION_OPTIONS_KEY); + if (!raw) return QR_CAPTION_DEFAULTS; + try { + const parsed = JSON.parse(raw) as Partial; + // Read field by field: anything absent or not a boolean falls back to the + // default, so a hand-edited or stale value cannot switch captions on. + return { + includeName: + typeof parsed.includeName === 'boolean' + ? parsed.includeName + : QR_CAPTION_DEFAULTS.includeName, + includeMedium: + typeof parsed.includeMedium === 'boolean' + ? parsed.includeMedium + : QR_CAPTION_DEFAULTS.includeMedium, + }; + } catch { + return QR_CAPTION_DEFAULTS; + } + }); + + const update = useCallback((next: QrCaptionOptions) => { + setOptions(next); + safeStorage.set(CAPTION_OPTIONS_KEY, JSON.stringify(next)); + }, []); + + return [options, update]; +} + function QRDialog({ qr, fallbackKey, @@ -140,25 +192,23 @@ function QRDialog({ ); const { dataUrl, failed } = useQRDataUrl(url); const [isDownloading, setIsDownloading] = useState(false); + const [caption, setCaption] = useCaptionOptions(); - const captionLines = [ - qr.name, - [qr.medium, qr.location].filter(Boolean).join(' · '), - ].filter(Boolean); + const captionLines = useMemo( + () => qrCaptionLines(qr, caption), + [qr, caption], + ); const handleDownload = async () => { setIsDownloading(true); try { - // A PNG with the name / medium / location printed underneath, saved via a - // blob. Previously this was ``: - // the file was unidentifiable in a downloads folder, the printed sheet had - // no label at all, and on iOS Safari a data: URL tends to navigate in-tab - // rather than save — destroying this dialog in the process. + // Saved via a blob. Previously this was + // ``: the file was + // unidentifiable in a downloads folder, and on iOS Safari a data: URL + // tends to navigate in-tab rather than save — destroying this dialog in + // the process. const blob = await qrPngBlob(url, captionLines); - downloadBlob( - blob, - `${slugForFilename(qr.name, qr.medium, qr.location)}.png`, - ); + downloadBlob(blob, qrPngFileName(qr.name)); } catch (error) { toast.error( error instanceof Error @@ -225,6 +275,35 @@ function QRDialog({ )} +
+ + {t('qrCodes.captionLegend')} + +
+ + setCaption({ ...caption, includeName }) + } + label={t('qrCodes.nameLabel')} + sample={qr.name} + /> + + setCaption({ ...caption, includeMedium }) + } + label={t('qrCodes.mediumLabel')} + sample={[qr.medium, qr.location].filter(Boolean).join(' · ')} + /> +
+

+ {captionLines.length + ? t('qrCodes.captionHint') + : t('qrCodes.captionHintNone')} +

+
+

{t('qrCodes.scanUrlLabel')} @@ -238,6 +317,44 @@ function QRDialog({ ); } +/** + * A caption checkbox that shows the text it would actually print. + * + * Without the sample the choice is abstract — "Medium" does not tell you that + * ticking it also prints the location alongside it. Showing the exact string + * means the bundling needs no explaining. + */ +function CaptionToggle({ + checked, + onChange, + label, + sample, +}: { + checked: boolean; + onChange: (next: boolean) => void; + label: string; + sample: string; +}) { + return ( + + ); +} + function QRCodesContent() { const { user } = useAuthContext(); const { t, locale } = useTranslation(); From 23b83d8ca25b596675aa94b5006d18179987922a Mon Sep 17 00:00:00 2001 From: kamijo-haruto Date: Tue, 28 Jul 2026 15:36:33 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[fix]=20=E3=83=95=E3=82=A9=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF=E7=94=A8=E3=82=AD=E3=83=BC?= =?UTF-8?q?=E3=83=AF=E3=83=BC=E3=83=89=E3=81=AB=E3=82=A2=E3=83=B3=E3=83=80?= =?UTF-8?q?=E3=83=BC=E3=83=90=E3=83=BC=E3=82=92=E8=A8=B1=E5=AE=B9=E3=81=97?= =?UTF-8?q?=E3=80=81X=E3=81=AE=E8=BB=A2=E9=80=81=E5=85=88=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X のアカウントが x.com/nut_fes で、キーワードにアンダーバーを使えないと ソーシャルのハンドルをそのまま設定できなかった。弾いていたのは入力検証だけで、 実行時に解決する fallback.ts はもともとキーの文字種を制限していない。 アンダーバーはQRコードのサイズに影響しない。RFC 3986 の unreserved 文字なので encodeURIComponent でパーセントエンコードされず、ペイロードは既にバイトモード (小文字はQRの英数字モードに無い)なので他の文字と同じ8ビットで済む。 文字クラスはハイフンを最後に置く。[a-z0-9-_] と書くと 9-_ が範囲として解釈され、 大文字と記号まで通ってしまう。この点は useFieldErrors.test.ts で固定した。 FALLBACK_DESTINATIONS には x と nut_fes の2つのキーを登録する。前者は deriveFallbackKey がホスト x.com から導出する値で、キーワード欄を空にした プロジェクトのQRに入る。後者は手で名付けるときに選ぶであろうハンドル名。 キーが一致しないと静的フォールバックに落ちてしまい、それはこの仕組みが 防ごうとしている失敗そのものなので、両方登録しておく。 Co-Authored-By: Claude Opus 5 (1M context) --- packages/api/src/routes/projects.ts | 11 ++- packages/api/wrangler.jsonc | 11 ++- packages/web/src/hooks/useFieldErrors.test.ts | 82 +++++++++++++++++++ packages/web/src/hooks/useFieldErrors.ts | 11 ++- packages/web/src/lib/i18n.tsx | 4 +- packages/web/src/lib/qr.test.ts | 19 ++++- packages/web/src/lib/qr.ts | 16 ++-- 7 files changed, 138 insertions(+), 16 deletions(-) create mode 100644 packages/web/src/hooks/useFieldErrors.test.ts diff --git a/packages/api/src/routes/projects.ts b/packages/api/src/routes/projects.ts index 43a484d..ab58f18 100644 --- a/packages/api/src/routes/projects.ts +++ b/packages/api/src/routes/projects.ts @@ -44,8 +44,15 @@ const fallbackKey = z .string() .max(FALLBACK_KEY_MAX) .regex( - /^$|^[a-z0-9][a-z0-9-]*$/, - 'Use lowercase letters, digits and hyphens only', + // Underscore is allowed because social handles use it — the X account is + // x.com/nut_fes — and it costs nothing to carry: `_` is unreserved in RFC + // 3986, so encodeURIComponent leaves it alone, and the QR payload is + // already in byte mode (lowercase letters are absent from QR's + // alphanumeric set), so it is the same 8 bits as any other character. + // Hyphen stays last in the class: `[a-z0-9-_]` would read `9-_` as a range + // and quietly admit uppercase and punctuation. + /^$|^[a-z0-9][a-z0-9_-]*$/, + 'Use lowercase letters, digits, hyphens and underscores only', ); const createProjectBodySchema = z.object({ diff --git a/packages/api/wrangler.jsonc b/packages/api/wrangler.jsonc index 982de01..1b04929 100644 --- a/packages/api/wrangler.jsonc +++ b/packages/api/wrangler.jsonc @@ -61,7 +61,16 @@ // outage rather than during one. "FALLBACK_DESTINATIONS": { "instagram": "https://www.instagram.com/nutfes?igsh=MTB1eXhpcWJ4YnQ0cA==", - "web": "https://www.nutfes.net/" + "web": "https://www.nutfes.net/", + // X is listed under two keys on purpose. "x" is what the admin UI derives + // from the host x.com, so it is the keyword a project gets if the field is + // left blank; "nut_fes" is the handle, which is what someone naming the + // keyword by hand reaches for. An unmatched keyword would silently send + // scans to FALLBACK_URL instead — exactly the case this map exists to + // prevent — and a spare entry costs nothing. Drop one once the projects + // are set up and you know which is in use. + "x": "https://x.com/nut_fes", + "nut_fes": "https://x.com/nut_fes" } } // Secrets (set with `wrangler secret put `, never committed): diff --git a/packages/web/src/hooks/useFieldErrors.test.ts b/packages/web/src/hooks/useFieldErrors.test.ts new file mode 100644 index 0000000..fdcda96 --- /dev/null +++ b/packages/web/src/hooks/useFieldErrors.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { validateFallbackKey, validateHttpUrl } from './useFieldErrors'; + +/** + * The fallback keyword is printed into QR codes, so the set of characters + * accepted here has to match the API's exactly — a key the form accepts but the + * API rejects is a save that fails after the user has already decided. + */ +describe('validateFallbackKey', () => { + const ok = (value: string) => expect(validateFallbackKey(value)).toBeNull(); + const rejected = (value: string) => + expect(validateFallbackKey(value)).toBe('validation.fallbackKey'); + + it('accepts underscores, which social handles need', () => { + // The X account is x.com/nut_fes. + ok('nut_fes'); + ok('a_b_c'); + }); + + it('accepts the existing keywords', () => { + ok('instagram'); + ok('web'); + ok('x'); + ok('my-shop'); + ok('a1'); + }); + + // The character class must be written [a-z0-9_-] with the hyphen last. + // [a-z0-9-_] parses `9-_` as a range spanning ':' through '_', which would + // wave through uppercase and punctuation. + it('still rejects everything outside the class', () => { + for (const value of [ + 'A', + 'Insta', + 'a b', + 'a.b', + 'a:b', + 'a@b', + 'a^b', + '@', + ]) { + rejected(value); + } + }); + + it('requires an alphanumeric first character', () => { + rejected('_leading'); + rejected('-leading'); + // Trailing is fine — only the first character is constrained. + ok('trailing_'); + ok('trailing-'); + }); + + it('rejects an empty value', () => { + // The field is optional, but blank is handled by not validating at all; + // reaching here with '' means a required-field rule was expected. + rejected(''); + }); + + it('rejects non-ASCII, which would inflate the printed QR', () => { + rejected('インスタ'); + }); +}); + +describe('validateHttpUrl', () => { + it('accepts http and https', () => { + expect(validateHttpUrl('https://x.com/nut_fes')).toBeNull(); + expect(validateHttpUrl('http://example.com')).toBeNull(); + }); + + // zod's .url() accepts these, which is how they became a stored-XSS route. + it('rejects other schemes', () => { + for (const value of [ + 'javascript:alert(1)', + 'data:text/html,