After an edit that keeps browser data in localStorage or sessionStorage, names each line, so the model stores the data in a cookie instead.

The model keeps a login token in localStorage because it is the shortest way, and any script on the page can now read it. This mod tells the model when an edit stores browser data with localStorage or sessionStorage instead of a cookie. The note comes with the Edit's result and names each line, so the model moves the data to a cookie in the same turn. By default nothing is stopped; in deny mode a commit, a push and a merge stop while a file still uses the storage.
.js, .jsx, .ts, .tsx, .mjs, .cjs, .mts, .cts, .vue, .svelte, .astro, .html), it reads the lines the edit added: those of new_string that old_string does not have, or every line of a Write. Test files are read too.localStorage or sessionStorage as code:| Counts | Does not count |
|---|---|
localStorage.setItem('token', token) | // localStorage.setItem(...) and <!-- ... --> comment lines |
window.sessionStorage.getItem(key) | save(token) // not localStorage, a comment after the code |
window['localStorage'] | "we never use localStorage", the word inside a string |
const { sessionStorage } = window | ` localStorage is off `, the word in a template literal's text |
` saved: ${localStorage.getItem(key)} , the ${...}` part | myLocalStorage, indexedDB, document.cookie |
storage-guard: this edit stores data in the browser with localStorage or sessionStorage: src/auth.ts:12. Store it in a cookie instead (document.cookie, or the server's Set-Cookie).
The line number comes from the file after the edit; a Write is numbered from its own content. At most 8 places are named, the rest counted. When the file cannot be read, the path stands without a line and the error is logged once. The path is written against the git repository the session started in when the file is inside it, so a session opened in api/ names a file of web/ as web/auth.ts. Outside a git repository it is written against the directory the session started in. That root is read once at the session's start, because a Bash cd moves the session's own directory.
storage-guard: browser storage instead of a cookie: src/auth.ts:12
The note and the line are separate channels: the model never reads the line, and you never read the note.
deny mode, and every measure replaces the finding with what the file holds now: the reported lines that still use the storage, at their current line numbers. A line that is gone or commented out no longer counts. A partial fix keeps the finding open with the places left. A file whose lines are all gone closes, and so does a file that is no longer there; a file that is there and cannot be read keeps its finding, because an unread file proves nothing. The closing entry is green:storage-guard: the browser storage is gone from src/auth.ts: src/auth.ts:12
With the sidebar closed the same text is one transcript line. The model reads nothing of this: it moved the data itself.
storage-guard: 1 place(s) still store data in localStorage or sessionStorage: src/auth.ts:12. Move the data to a cookie, or take the lines out.
One note per turn, not one per prompt. Without this the finding would be said once, at the edit, and then stand in the pane while the model forgot it. You read nothing new: the pane already carries the same finding.
deny mode the mod also stops git commit, git push and git merge while a file still uses the storage; a command with --dry-run, --help or -h is not stopped. Before it stops one it reads each open file again, so a file the model fixed opens the gate itself. A git commit answers for its own files alone: the mod reads the index (git diff --cached --name-only -z) and lets the commit run when it holds none of the open files, with one line to you naming how many still stand. A push and a merge hold no index to read, so every finding stands there. There is no bypass; only you turn the gate off, with /storage-guard mode note. note mode is the default and stops nothing.In the live check the model added localStorage.setItem('token', token) to a file with one Edit and quoted the note naming app.ts:2 word for word. At the next prompt it quoted the turn-end note, rewrote the line with document.cookie, and the closing line the browser storage is gone from app.ts: app.ts:2 followed that Edit.
/storage-guard on or off, the mode, and the files still using the storage /storage-guard on | off on by default /storage-guard mode note note only; the default /storage-guard mode deny a commit, a push and a merge also stop while a file uses the storage
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install storage-guard@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.ts hooks: session.start, command.run{command=storage-guard}, turn.complete, prompt.submit, tool.call{tool=Bash}, tool.call{tool=Edit}, tool.call{tool=Write} ❯ ./register.ts calls: $.command.register, $.fs.exists (via isGone), $.fs.read (via fileText), $.process.run (via shownRootOf, stagedPaths), $.session.cwd, $.sidebar.clear (via dropEntry), $.sidebar.set (via toPerson), $.store.get (via readSettings), $.store.set (via runCommand, setMode), $.ui.log (via fileText, gate, toPerson)
Reach L2, it runs git to read the index.
onclick="localStorage.clear()") is read as a string and does not count.const s = window[name], a wrapper library, indexedDB) is not seen.deny mode has no bypass. When a finding cannot be fixed, you turn the gate off with /storage-guard mode note.git commit is not stopped.git commit -a, a -am and a commit with a pathspec after -- are not narrowed to the index, because they commit files the index does not hold yet. Every open finding stands for those.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 257 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { denyText, doneLines, doneLog, isCommit, isGuarded, isNarrowable, isSource, logText, modeOf, noteText, openNote, placesOf, sectionKey, shownPath, sidebarLines, stillUsed, storageLines, type Line, type Mode } from './storage.ts'
3
4const ENABLED_KEY = 'enabled'
5const MODE_KEY = 'mode'
6
7const USAGE = 'expects nothing (the status), on, off or mode note | deny'
8
9/** One file's open finding: the file on disk, the lines that use the storage, and where they stand in it. */
10type Finding = { path: string; lines: string[]; places: string[] }
11
12/**
13 * The on/off setting and the mode as the store held them at the last read, whether a read error was
14 * logged, the open findings by shown path, whether the model is owed a note for them, and the root read
15 * once at the session's start (`shownRootOf`). A path is shown against that root, not against
16 * `$.session.cwd()`, because a Bash `cd` moves the session's directory and would then leave every path
17 * outside it written in full.
18 */
19type State = { enabled: boolean; mode: Mode; reported: boolean; open: Map<string, Finding>; owed: boolean; root?: string }
20
21/**
22 * Reads the on/off setting and the mode from the store, which every window shares, so a change made in
23 * another window applies here at the next hook that acts on it.
24 */
25async function readSettings($: EngineInterface, state: State): Promise<void> {
26 state.enabled = (await $.store.get(ENABLED_KEY)) !== false
27 state.mode = (await $.store.get(MODE_KEY)) === 'deny' ? 'deny' : 'note'
28}
29
30/**
31 * The git repository the session started in, so a file in a sibling directory of a session opened in a
32 * subdirectory still reads short; the session's own directory where git does not answer.
33 */
34async function shownRootOf($: EngineInterface, cwd: string): Promise<string> {
35 try {
36 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
37 const root = top.stdout.trim()
38 return top.exitCode === 0 && root !== '' ? root : cwd
39 } catch {
40 // No git here, or the command did not run: paths are shown against the session's directory.
41 return cwd
42 }
43}
44
45/** Whether the file is no longer there, so a finding of it closes instead of standing for good. */
46async function isGone($: EngineInterface, path: string): Promise<boolean> {
47 try {
48 return !(await $.fs.exists(path))
49 } catch {
50 // The path was not measured: the finding is left as it stands.
51 return false
52 }
53}
54
55/** The file's text, or undefined when it cannot be read; the first failure is logged. */
56async function fileText($: EngineInterface, state: State, path: string): Promise<string | undefined> {
57 try {
58 return await $.fs.read(path)
59 } catch (err) {
60 if (!state.reported) $.ui.log(`the edited file was not read, its places stay as reported: ${err instanceof Error ? err.message : String(err)}`)
61 state.reported = true
62 return undefined
63 }
64}
65
66/**
67 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
68 * transcript line. The model's note is another channel and does not change here.
69 */
70async function toPerson($: EngineInterface, key: string, title: string, lines: Line[], line: string): Promise<void> {
71 try {
72 const taken = await $.sidebar.set({ consumer: 'storage-guard', key: sectionKey(key), title, lines, until: 'stream' })
73 if (taken) return
74 } catch {
75 // The sidebar mod is not installed.
76 }
77 $.ui.log(line)
78}
79
80/** Drops the sidebar entries of one finding, so a file that no longer uses the storage leaves no warning behind. */
81async function dropEntry($: EngineInterface, key: string): Promise<void> {
82 try {
83 await $.sidebar.clear({ consumer: 'storage-guard', key: sectionKey(key) })
84 } catch {
85 // The sidebar mod is not installed.
86 }
87}
88
89/**
90 * Reads each open file again. A finding whose lines are all gone closes; one that keeps some of them is
91 * replaced by what the file holds now, never joined with what it held before. An unreadable file stays
92 * as it stands. `skip` is the file this edit just measured, so the mod does not read it a second time.
93 */
94async function closeResolved($: EngineInterface, state: State, skip?: string): Promise<void> {
95 for (const [shown, found] of [...state.open]) {
96 if (shown === skip) continue
97 // A file that is gone holds no line any more; one that is there and unreadable proves nothing.
98 const text = (await isGone($, found.path)) ? '' : await fileText($, state, found.path)
99 if (text === undefined) continue
100 const left = stillUsed(text, found.lines)
101 if (left.length > 0) {
102 state.open.set(shown, { path: found.path, lines: left, places: placesOf(shown, text, left) })
103 continue
104 }
105 state.open.delete(shown)
106 await dropEntry($, shown)
107 await toPerson($, shown, 'cookie used instead', doneLines(shown, found.places), doneLog(shown, found.places))
108 }
109}
110
111/** The note of one edit and the file it named, and the finding it opens; undefined when the edit adds no storage use. */
112async function noteFor($: EngineInterface, state: State, path: string, before: string, after: string): Promise<{ note: string; shown: string } | undefined> {
113 const lines = storageLines(before, after)
114 if (lines.length === 0) return undefined
115 const shown = shownPath(path, state.root ?? (await $.session.cwd()))
116 const text = before === '' ? after : await fileText($, state, path)
117 const held = state.open.get(shown)?.lines ?? []
118 // The lines reported before are measured in the file as it is now, then this edit's lines join them.
119 const kept = text === undefined ? held : stillUsed(text, held)
120 const all = [...new Set([...kept, ...lines])]
121 state.open.set(shown, { path, lines: all, places: placesOf(shown, text, all) })
122 const places = placesOf(shown, text, lines)
123 // The note goes to the model, the line to the person: neither reads the other's channel.
124 await toPerson($, shown, 'browser storage instead of a cookie', sidebarLines(places), logText(places))
125 return { note: noteText(places), shown }
126}
127
128/** Adds the note to an edit whose new lines use the storage, and closes what a later edit fixed. */
129async function afterEdit($: EngineInterface, state: State, path: string, before: string, after: string, r: ToolCallResult): Promise<ToolCallResult> {
130 if (r.deny !== undefined || r.isError === true) return r
131 await readSettings($, state)
132 if (!state.enabled) return r
133 const found = isSource(path) ? await noteFor($, state, path, before, after) : undefined
134 await closeResolved($, state, found?.shown)
135 return found === undefined ? r : { ...r, context: [...(r.context ?? []), found.note] }
136}
137
138/**
139 * The files this commit holds, by absolute path, or undefined when git did not answer. Read before the
140 * command runs, so it is the index as the commit will take it.
141 */
142async function stagedPaths($: EngineInterface, state: State): Promise<Set<string> | undefined> {
143 try {
144 const cwd = state.root ?? (await $.session.cwd())
145 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
146 const staged = await $.process.run(['git', 'diff', '--cached', '--name-only', '-z'], { cwd })
147 if (top.exitCode !== 0 || staged.exitCode !== 0) return undefined
148 const base = top.stdout.trim()
149 return new Set(staged.stdout.split('\0').filter(Boolean).map(p => `${base}/${p}`))
150 } catch {
151 // No git here, or the command did not run: the findings are not narrowed.
152 return undefined
153 }
154}
155
156/**
157 * The findings this command answers for. A `git commit` answers for its own files alone, so a finding of
158 * a file the commit does not hold lets it run. A `push` or a `merge` holds no index to read, so every
159 * finding stands there.
160 */
161async function scopeOf($: EngineInterface, state: State, command: string): Promise<Finding[]> {
162 const all = [...state.open.values()]
163 if (!isCommit(command) || !isNarrowable(command)) return all
164 const staged = await stagedPaths($, state)
165 return staged === undefined ? all : all.filter(f => staged.has(f.path))
166}
167
168/**
169 * The gate of the `deny` mode: it reads each open file again, so a file the model fixed without a new
170 * finding opens the gate too. A file this command holds that still uses the storage stops it, and there
171 * is no bypass.
172 */
173async function gate($: EngineInterface, state: State, command: string): Promise<string | undefined> {
174 if (state.open.size === 0 || !isGuarded(command)) return undefined
175 await readSettings($, state)
176 if (!state.enabled || state.mode !== 'deny') return undefined
177 await closeResolved($, state)
178 if (state.open.size === 0) return undefined
179 const scoped = await scopeOf($, state, command)
180 if (scoped.length === 0) {
181 $.ui.log(`${state.open.size} file(s) still use localStorage or sessionStorage, and this command holds none of them`)
182 return undefined
183 }
184 return denyText(scoped.flatMap(f => f.places))
185}
186
187async function setMode($: EngineInterface, state: State, word: string): Promise<string> {
188 const mode = modeOf(word)
189 if (mode === undefined) return 'mode expects note or deny'
190 await $.store.set(MODE_KEY, mode)
191 state.mode = mode
192 return mode === 'deny' ? 'mode deny: git commit, push and merge stop while a file uses localStorage or sessionStorage' : 'mode note: the places are only reported'
193}
194
195function statusText(state: State): string {
196 const open = state.open.size === 0 ? 'no file is open' : `${state.open.size} file(s) still use localStorage or sessionStorage`
197 return `${state.enabled ? 'on' : 'off'} · mode ${state.mode} · ${open}`
198}
199
200async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
201 const word = args.trim()
202 if (word === 'on' || word === 'off') {
203 await $.store.set(ENABLED_KEY, word === 'on')
204 state.enabled = word === 'on'
205 return word === 'on' ? 'on: each edit is checked for localStorage and sessionStorage' : 'off: edits are not checked'
206 }
207 if (word.startsWith('mode')) return setMode($, state, word.slice(4).trim())
208 if (word !== '') return USAGE
209 await readSettings($, state)
210 return statusText(state)
211}
212
213export const register: Register = on => {
214 const state: State = { enabled: true, mode: 'note', reported: false, open: new Map(), owed: false }
215
216 on('session.start', async ($, e, next) => {
217 const r = await next(e)
218 await $.command.register({ name: 'storage-guard', description: 'localStorage and sessionStorage an edit adds: status, on, off, mode (storage-guard)', argumentHint: '[on | off | mode note | deny]' })
219 await readSettings($, state)
220 state.root = await shownRootOf($, await $.session.cwd())
221 return r
222 })
223
224 // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
225 on('command.run', { command: 'storage-guard' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
226
227 /*
228 * The turn's end reads every open file again and owes the model a note for what is left, because a
229 * finding it did not close would otherwise stand in the pane and reach it never again.
230 */
231 on('turn.complete', async ($, e, next) => {
232 const r = await next(e)
233 if (e.agentId !== undefined) return r
234 await readSettings($, state)
235 if (!state.enabled) return r
236 await closeResolved($, state)
237 state.owed = state.open.size > 0
238 return r
239 })
240
241 // The note goes to the model alone; the person reads the pane, which carries the same finding.
242 on('prompt.submit', async (_, e, next) => {
243 if (!state.owed || state.open.size === 0) return next(e)
244 state.owed = false
245 const note = openNote([...state.open.values()].flatMap(f => f.places))
246 return next({ ...e, context: [...(e.context ?? []), note] })
247 })
248
249 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
250 const stop = await gate($, state, e.command)
251 return stop === undefined ? next(e) : { deny: stop }
252 })
253
254 on('tool.call', { tool: 'Edit' }, async ($, e, next) => afterEdit($, state, e.file_path, e.old_string, e.new_string, await next(e)))
255 on('tool.call', { tool: 'Write' }, async ($, e, next) => afterEdit($, state, e.file_path, '', e.content, await next(e)))
256}
257hooks/storage.ts 167 lines1/** Lines of source code that keep browser data in localStorage or sessionStorage instead of a cookie. */
2
3/** Files whose lines are read: JavaScript, TypeScript and the component files that hold them. */
4const SOURCE = /\.(js|jsx|ts|tsx|mjs|cjs|mts|cts|vue|svelte|astro|html)$/
5
6/** The storage named as code: `localStorage.setItem`, `window.sessionStorage`, `{ localStorage }`. */
7const STORAGE = /\b(local|session)Storage\b/
8
9/** The storage named in brackets: `window['localStorage']`. */
10const BRACKET = /\[\s*(['"`])(local|session)Storage\1\s*\]/
11
12/** A line that starts as a comment in JavaScript, TypeScript or HTML. */
13const COMMENT = /^\s*(\/\/|\*|\/\*|<!--)/
14
15/** Quoted string literals, template literals, and what is left of a line after a comment starts on it. */
16const QUOTED = /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g
17const TEMPLATE = /`(?:\\.|[^`\\])*`/g
18const INTERPOLATION = /\$\{[^}]*\}/g
19const TAIL_COMMENT = /\/\/.*$|\/\*.*?\*\//g
20
21export function isSource(path: string): boolean {
22 return SOURCE.test(path)
23}
24
25/** The line without its string text; a template literal keeps its `${...}` parts, which are code. */
26function codeOf(line: string): string {
27 return line
28 .replace(TEMPLATE, t => (t.match(INTERPOLATION) ?? []).join(' '))
29 .replace(QUOTED, '""')
30 .replace(TAIL_COMMENT, '')
31}
32
33/** Whether the line uses the storage as code; a word inside a string or a comment is not a use. */
34function usesStorage(line: string): boolean {
35 if (COMMENT.test(line)) return false
36 return BRACKET.test(line) || STORAGE.test(codeOf(line))
37}
38
39/** The reported lines the file still uses the storage on; a line now commented out counts as fixed. */
40export function stillUsed(text: string, lines: string[]): string[] {
41 const used = text.split('\n').filter(usesStorage)
42 return lines.filter(l => used.some(u => u.includes(l)))
43}
44
45/** The trimmed lines of `after` that use the storage, without those `before` already had. */
46export function storageLines(before: string, after: string): string[] {
47 const old = new Set(before.split('\n').map(l => l.trim()))
48 const found = after.split('\n').filter(usesStorage).map(l => l.trim())
49 return [...new Set(found)].filter(l => l !== '' && !old.has(l))
50}
51
52/** The 1-based number of the first line of `text` that holds `line`; an Edit's text can be part of a line. */
53export function lineOf(text: string, line: string): number | undefined {
54 const i = text.split('\n').findIndex(l => l.includes(line))
55 return i < 0 ? undefined : i + 1
56}
57
58/** Each line as a place in the file: `file:line`, or the file alone when the text was not read. */
59export function placesOf(shown: string, text: string | undefined, lines: string[]): string[] {
60 return lines.map(l => {
61 const n = text === undefined ? undefined : lineOf(text, l)
62 return n === undefined ? shown : `${shown}:${n}`
63 })
64}
65
66/** `path` shown relative to the session directory when it is inside it. */
67export function shownPath(path: string, cwd: string): string {
68 const base = `${cwd.replace(/\/+$/, '')}/`
69 return path.startsWith(base) ? path.slice(base.length) : path
70}
71
72/** At most this many places are named, the rest counted. */
73const MAX_NAMED = 8
74
75function namedPlaces(places: string[]): string {
76 const named = places.slice(0, MAX_NAMED)
77 if (places.length > MAX_NAMED) named.push(`${places.length - MAX_NAMED} more`)
78 return named.join(' · ')
79}
80
81export function noteText(places: string[]): string {
82 return `storage-guard: this edit stores data in the browser with localStorage or sessionStorage: ${namedPlaces(places)}. Store it in a cookie instead (document.cookie, or the server's Set-Cookie).`
83}
84
85/**
86 * The note the model reads at the next prompt while a finding stands, so a finding it did not close
87 * reaches it again instead of standing in the pane alone. The person reads the pane and needs no line.
88 */
89export function openNote(places: string[]): string {
90 return `storage-guard: ${places.length} place(s) still store data in localStorage or sessionStorage: ${namedPlaces(places)}. Move the data to a cookie, or take the lines out.`
91}
92
93/** The transcript line: the places alone, without the instruction the model reads. The engine adds the mod name. */
94export function logText(places: string[]): string {
95 return `browser storage instead of a cookie: ${namedPlaces(places)}`
96}
97
98/** A sidebar line of a finding or its closing; the count of the places left unnamed is faint. */
99export type Line = { text: string; kind: 'error' | 'ok' | 'dim' }
100
101/**
102 * One line per named place in the finding's colour, and the places past `MAX_NAMED` as one faint count,
103 * so the count does not read as one more place.
104 */
105function placeLines(places: string[], kind: 'error' | 'ok'): Line[] {
106 const named: Line[] = places.slice(0, MAX_NAMED).map(text => ({ text, kind }))
107 return places.length > MAX_NAMED ? [...named, { text: `${places.length - MAX_NAMED} more`, kind: 'dim' }] : named
108}
109
110/** One sidebar line per place, so the section reads as a list. */
111export function sidebarLines(places: string[]): Line[] {
112 return placeLines(places, 'error')
113}
114
115/** The transcript line of a finding a later edit closed. */
116export function doneLog(file: string, places: string[]): string {
117 return `the browser storage is gone from ${file}: ${namedPlaces(places)}`
118}
119
120/** The sidebar lines of a closed finding: the file, then the places the storage left. */
121export function doneLines(file: string, places: string[]): Line[] {
122 return [{ text: file, kind: 'ok' }, ...placeLines(places, 'ok')]
123}
124
125/** The global flags git takes before the subcommand, so `git -c user.name=x commit` is still a commit. */
126const GIT_FLAG = String.raw`(?:\s+-[cC]\s+\S+|\s+--(?:git-dir|work-tree|namespace)=\S+|\s+--(?:no-pager|no-replace-objects|bare|literal-pathspecs|paginate))`
127
128/** A `git commit`, `git push` or `git merge` the gate stops while a finding is open. */
129const GUARDED = new RegExp(String.raw`(^|[\s;&|(])git(?:${GIT_FLAG})*\s+(commit|push|merge)\b`)
130const ASKING = /\s(--dry-run|--help|-h)(\s|$)/
131
132export function isGuarded(command: string): boolean {
133 return GUARDED.test(command) && !ASKING.test(command)
134}
135
136/** Whether the command is a `git commit`, the one guarded command whose own files can be measured. */
137export function isCommit(command: string): boolean {
138 return GUARDED.exec(command)?.[2] === 'commit'
139}
140
141/**
142 * Whether the index alone says what this commit holds. A `-a` or `-am` commit stages the tracked files
143 * as it runs, and a pathspec after `--` commits paths the index does not hold, so neither is narrowed.
144 */
145export function isNarrowable(command: string): boolean {
146 const words = command.split(/\s+/)
147 return !words.includes('--') && !words.some(w => w === '--all' || /^-[A-Za-z]*a/.test(w))
148}
149
150/** The mode of the mod: a note only, or a note and a gate on git commit, push and merge. */
151export type Mode = 'note' | 'deny'
152
153/** The mode a `/storage-guard mode <word>` argument names, or undefined when it is not one. */
154export function modeOf(arg: string): Mode | undefined {
155 return arg === 'note' || arg === 'deny' ? arg : undefined
156}
157
158/** The deny text both the model and the person read: where the storage is used, and the one way out. */
159export function denyText(places: readonly string[]): string {
160 return `stopped: ${places.length} place(s) store data in localStorage or sessionStorage: ${namedPlaces([...places])}. Move the data to a cookie, then run the command again; there is no way around this gate.`
161}
162
163/** A sidebar section key: the subject cut to what the sidebar takes, so one file keeps one section. */
164export function sectionKey(text: string): string {
165 return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'note'
166}
167