@crombolo/render 0.0.0-stage → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +36 -4
- package/src/context.ts +112 -0
- package/src/form-script.ts +112 -0
- package/src/forms.ts +557 -0
- package/src/html.ts +285 -0
- package/src/index.ts +16 -0
- package/src/jsx-attributes.ts +91 -0
- package/src/jsx-runtime.ts +334 -0
- package/src/loose/jsx-runtime.ts +21 -0
- package/src/media.ts +205 -0
- package/src/model.ts +303 -0
- package/src/published-cache.ts +865 -0
- package/src/redirects.ts +131 -0
- package/src/renderer.ts +374 -0
- package/src/seo.ts +209 -0
- package/src/sitemap.ts +99 -0
- package/src/snapshots.ts +398 -0
- package/src/url-segment.ts +34 -0
- package/src/values.ts +325 -0
- package/README.md +0 -3
package/src/snapshots.ts
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressed snapshots of the views tree.
|
|
3
|
+
*
|
|
4
|
+
* A view is a module, and `import()` caches modules in the runtime keyed on the
|
|
5
|
+
* resolved path. There is no eviction, a query string does not bust it on Bun,
|
|
6
|
+
* and the graph beneath a re-imported file stays pinned on every runtime. So a
|
|
7
|
+
* template edited under a running node keeps rendering as it did at first
|
|
8
|
+
* import, and no cache this codebase owns can change that.
|
|
9
|
+
*
|
|
10
|
+
* Only a new path is read fresh. A structure-preserving copy of the whole tree
|
|
11
|
+
* at a new path therefore re-resolves the whole graph — which is why the unit of
|
|
12
|
+
* versioning is the tree and not the file: `homePage.tsx` can be byte-identical
|
|
13
|
+
* while the layout it imports has changed.
|
|
14
|
+
*
|
|
15
|
+
* alias → <cacheDir>/<hash>/<alias>.tsx
|
|
16
|
+
*
|
|
17
|
+
* `sourceDir` stays the truth, for the template editor, `listComponents` and
|
|
18
|
+
* `views check`. A snapshot is only ever an import target.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from 'node:crypto'
|
|
21
|
+
import {
|
|
22
|
+
existsSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
readdirSync,
|
|
25
|
+
renameSync,
|
|
26
|
+
rmSync,
|
|
27
|
+
statSync,
|
|
28
|
+
writeFileSync,
|
|
29
|
+
} from 'node:fs'
|
|
30
|
+
import { join, relative, sep } from 'node:path'
|
|
31
|
+
|
|
32
|
+
export interface SnapshotLimits {
|
|
33
|
+
/** How long a stat gate's answer is trusted before another render re-checks. */
|
|
34
|
+
gateTtlMs: number
|
|
35
|
+
/** How long after an announced write a node keeps checking eagerly. */
|
|
36
|
+
eagerWindowMs: number
|
|
37
|
+
/** The floor between eager checks, so eagerness is frequent rather than per render. */
|
|
38
|
+
eagerGateMs: number
|
|
39
|
+
/** The floor between swaps; a burst of writes collapses to the latest tree. */
|
|
40
|
+
minSwapIntervalMs: number
|
|
41
|
+
/** Distinct generations this process will load before it freezes. */
|
|
42
|
+
maxGenerations: number
|
|
43
|
+
/** Previous generations left on disk, for an instant return to one. */
|
|
44
|
+
keep: number
|
|
45
|
+
maxFiles: number
|
|
46
|
+
maxBytes: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const SNAPSHOT_LIMITS: SnapshotLimits = {
|
|
50
|
+
gateTtlMs: 5_000,
|
|
51
|
+
eagerWindowMs: 20_000,
|
|
52
|
+
eagerGateMs: 500,
|
|
53
|
+
minSwapIntervalMs: 10_000,
|
|
54
|
+
// At ~88 KB of registry per generation on a small tree, a budget of 250 is
|
|
55
|
+
// about 22 MB — enough that nobody reaches it by working, low enough that a
|
|
56
|
+
// runaway writer is bounded. A bigger tree leaks proportionally more.
|
|
57
|
+
maxGenerations: 250,
|
|
58
|
+
keep: 3,
|
|
59
|
+
maxFiles: 2_000,
|
|
60
|
+
maxBytes: 20 * 1024 * 1024,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Development wants to see an edit at once and does not need the burst floor.
|
|
65
|
+
*
|
|
66
|
+
* It evicts nothing at runtime: deleting a file `bun --watch` has imported
|
|
67
|
+
* restarts the process, which is the very thing a snapshot exists to avoid.
|
|
68
|
+
* `prepare` clears the lot at boot instead.
|
|
69
|
+
*/
|
|
70
|
+
export const DEVELOPMENT_LIMITS: Partial<SnapshotLimits> = {
|
|
71
|
+
gateTtlMs: 500,
|
|
72
|
+
eagerGateMs: 0,
|
|
73
|
+
minSwapIntervalMs: 0,
|
|
74
|
+
keep: Number.POSITIVE_INFINITY,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SnapshotStatus {
|
|
78
|
+
/** The generation being rendered, or undefined before the first is taken. */
|
|
79
|
+
hash: string | undefined
|
|
80
|
+
/** Distinct generations loaded by this process. */
|
|
81
|
+
generations: number
|
|
82
|
+
/** The budget is spent: the current generation keeps serving, new ones are refused. */
|
|
83
|
+
frozen: boolean
|
|
84
|
+
/** Why the last snapshot was refused, if one was. */
|
|
85
|
+
refused: string | undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface SnapshotOptions {
|
|
89
|
+
/** Where the views really live; read, never imported. */
|
|
90
|
+
sourceDir: string
|
|
91
|
+
/** Where snapshots are written. Must be outside `sourceDir`. */
|
|
92
|
+
cacheDir: string
|
|
93
|
+
limits?: Partial<SnapshotLimits>
|
|
94
|
+
/** Reported once per distinct problem, not per render. */
|
|
95
|
+
onProblem?: (message: string) => void
|
|
96
|
+
/**
|
|
97
|
+
* A generation came into use, with the one it replaced. Undefined on the first,
|
|
98
|
+
* which is every boot and not an event worth reporting.
|
|
99
|
+
*/
|
|
100
|
+
onSwap?: (hash: string, previous: string | undefined) => void
|
|
101
|
+
now?: () => number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface FileEntry {
|
|
105
|
+
/** Path relative to the source root, `/`-separated so a hash is portable. */
|
|
106
|
+
key: string
|
|
107
|
+
absolute: string
|
|
108
|
+
size: number
|
|
109
|
+
mtimeMs: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const PARTIAL = '.partial-'
|
|
113
|
+
|
|
114
|
+
/** `/`-separated, whatever the host platform writes. */
|
|
115
|
+
const toKey = (path: string) => path.split(sep).join('/')
|
|
116
|
+
|
|
117
|
+
function walk(dir: string, root = dir, out: FileEntry[] = []): FileEntry[] {
|
|
118
|
+
// Sorted, so two nodes reading the same tree hash it the same way.
|
|
119
|
+
for (const name of readdirSync(dir).sort()) {
|
|
120
|
+
const absolute = join(dir, name)
|
|
121
|
+
const stats = statSync(absolute)
|
|
122
|
+
if (stats.isDirectory()) walk(absolute, root, out)
|
|
123
|
+
else
|
|
124
|
+
out.push({
|
|
125
|
+
key: toKey(relative(root, absolute)),
|
|
126
|
+
absolute,
|
|
127
|
+
size: stats.size,
|
|
128
|
+
mtimeMs: stats.mtimeMs,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
return out
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export class ViewSnapshots {
|
|
135
|
+
#sourceDir: string
|
|
136
|
+
#cacheDir: string
|
|
137
|
+
#limits: SnapshotLimits
|
|
138
|
+
#onProblem: (message: string) => void
|
|
139
|
+
#onSwap: (hash: string, previous: string | undefined) => void
|
|
140
|
+
#now: () => number
|
|
141
|
+
|
|
142
|
+
#current: { hash: string; dir: string } | undefined
|
|
143
|
+
/** Every hash this process has imported from: returning to one costs nothing. */
|
|
144
|
+
#loaded = new Set<string>()
|
|
145
|
+
#gateHash: string | undefined
|
|
146
|
+
#gateAt = 0
|
|
147
|
+
#swappedAt = 0
|
|
148
|
+
#eagerUntil = 0
|
|
149
|
+
#announced: string | undefined
|
|
150
|
+
#checking: Promise<void> | undefined
|
|
151
|
+
#forceNext = false
|
|
152
|
+
#frozen = false
|
|
153
|
+
#refused: string | undefined
|
|
154
|
+
#reported = new Set<string>()
|
|
155
|
+
|
|
156
|
+
constructor(options: SnapshotOptions) {
|
|
157
|
+
this.#sourceDir = options.sourceDir
|
|
158
|
+
this.#cacheDir = options.cacheDir
|
|
159
|
+
this.#limits = { ...SNAPSHOT_LIMITS, ...options.limits }
|
|
160
|
+
this.#onProblem = options.onProblem ?? (() => {})
|
|
161
|
+
this.#onSwap = options.onSwap ?? (() => {})
|
|
162
|
+
this.#now = options.now ?? Date.now
|
|
163
|
+
if (!relative(this.#sourceDir, this.#cacheDir).startsWith('..')) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`The views cache directory must sit outside the views directory; ${this.#cacheDir} is inside ${this.#sourceDir}.`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Each distinct problem once: a render path must not fill the log. */
|
|
171
|
+
#problem(message: string): void {
|
|
172
|
+
this.#refused = message
|
|
173
|
+
if (this.#reported.has(message)) return
|
|
174
|
+
this.#reported.add(message)
|
|
175
|
+
this.#onProblem(message)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Where generations are written; the renderer maps paths out of it for people. */
|
|
179
|
+
get cacheDir(): string {
|
|
180
|
+
return this.#cacheDir
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
status(): SnapshotStatus {
|
|
184
|
+
return {
|
|
185
|
+
hash: this.#current?.hash,
|
|
186
|
+
generations: this.#loaded.size,
|
|
187
|
+
frozen: this.#frozen,
|
|
188
|
+
refused: this.#refused,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Clears what a previous process left and takes the first generation, so no
|
|
194
|
+
* request pays for it and a crashed partial copy is never imported.
|
|
195
|
+
*/
|
|
196
|
+
async prepare(): Promise<void> {
|
|
197
|
+
mkdirSync(this.#cacheDir, { recursive: true })
|
|
198
|
+
// Everything, not just the partials: a generation a previous process
|
|
199
|
+
// imported is not in this one's registry, so keeping it buys nothing and a
|
|
200
|
+
// fresh boot is the only safe moment to delete what `--watch` is watching.
|
|
201
|
+
for (const name of readdirSync(this.#cacheDir))
|
|
202
|
+
rmSync(join(this.#cacheDir, name), { recursive: true, force: true })
|
|
203
|
+
await this.#check()
|
|
204
|
+
await this.settle()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The directory to import from.
|
|
209
|
+
*
|
|
210
|
+
* Synchronous and cheap: it answers from the current generation and, when the
|
|
211
|
+
* gate has gone stale, starts a check in the background. A render never waits
|
|
212
|
+
* for one, and concurrent renders share the single check in flight.
|
|
213
|
+
*/
|
|
214
|
+
directory(): string | undefined {
|
|
215
|
+
const now = this.#now()
|
|
216
|
+
// Eager means often, not every render: a stat walk is a ListObjects call on
|
|
217
|
+
// a mounted bucket, and a render must not pay for one.
|
|
218
|
+
const eager = now < this.#eagerUntil && now - this.#gateAt >= this.#limits.eagerGateMs
|
|
219
|
+
const due = eager || now - this.#gateAt >= this.#limits.gateTtlMs
|
|
220
|
+
if (due && !this.#checking) {
|
|
221
|
+
this.#checking = this.#check()
|
|
222
|
+
.catch((error: unknown) => {
|
|
223
|
+
this.#problem(`Could not refresh the views snapshot: ${String(error)}`)
|
|
224
|
+
})
|
|
225
|
+
.finally(() => {
|
|
226
|
+
this.#checking = undefined
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
return this.#current?.dir
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Another node says a template was written.
|
|
234
|
+
*
|
|
235
|
+
* The hash is a hint for when to stop looking, never what to render: this node
|
|
236
|
+
* always snapshots the bytes it reads, so it converges on whatever the tree
|
|
237
|
+
* actually is even when the hash it was handed is already superseded. If it is
|
|
238
|
+
* never reached, the window closing ends the eagerness.
|
|
239
|
+
*/
|
|
240
|
+
announce(hash?: string): void {
|
|
241
|
+
this.#announced = hash
|
|
242
|
+
this.#eagerUntil = this.#now() + this.#limits.eagerWindowMs
|
|
243
|
+
this.#gateAt = 0
|
|
244
|
+
// A deliberate write, relayed from the node that made it. The floor exists
|
|
245
|
+
// to absorb a flapping sync, which is by definition unannounced; applying it
|
|
246
|
+
// here would make an editor's save take the floor's length to reach the
|
|
247
|
+
// renderers even though somebody asked for it.
|
|
248
|
+
this.#forceNext = true
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A template was written *here*: take the snapshot now and report its hash.
|
|
253
|
+
*
|
|
254
|
+
* The coalescing floor is skipped, for two reasons. An editor must see their
|
|
255
|
+
* own save rather than the previous template for the next ten seconds; and the
|
|
256
|
+
* floor exists to absorb a flapping sync, which is by definition something no
|
|
257
|
+
* node announced. A runaway announced writer is bounded by the generation
|
|
258
|
+
* budget instead, which is what the budget is for.
|
|
259
|
+
*/
|
|
260
|
+
async refresh(): Promise<string | undefined> {
|
|
261
|
+
await this.settle()
|
|
262
|
+
this.#checking = this.#check(true).finally(() => {
|
|
263
|
+
this.#checking = undefined
|
|
264
|
+
})
|
|
265
|
+
await this.#checking
|
|
266
|
+
return this.#current?.hash
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Waits for any check in flight; tests and `prepare` want the settled state. */
|
|
270
|
+
async settle(): Promise<void> {
|
|
271
|
+
while (this.#checking) await this.#checking
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async #check(force = false): Promise<void> {
|
|
275
|
+
const now = this.#now()
|
|
276
|
+
this.#gateAt = now
|
|
277
|
+
if (!existsSync(this.#sourceDir)) return
|
|
278
|
+
|
|
279
|
+
const files = walk(this.#sourceDir)
|
|
280
|
+
const gate = createHash('sha256')
|
|
281
|
+
for (const file of files) gate.update(`${file.key}\0${file.size}\0${file.mtimeMs}\n`)
|
|
282
|
+
const gateHash = gate.digest('hex')
|
|
283
|
+
|
|
284
|
+
// Nothing the filesystem can see has moved.
|
|
285
|
+
if (gateHash === this.#gateHash && this.#current) {
|
|
286
|
+
if (this.#announced !== undefined && this.#announced === this.#current.hash)
|
|
287
|
+
this.#eagerUntil = 0
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// A burst of unannounced changes collapses into one generation. The first
|
|
292
|
+
// snapshot is never delayed, or a boot would serve nothing, and an announced
|
|
293
|
+
// write is not a burst — see `refresh`.
|
|
294
|
+
const forced = force || this.#forceNext
|
|
295
|
+
this.#forceNext = false
|
|
296
|
+
if (!forced && this.#current && now - this.#swappedAt < this.#limits.minSwapIntervalMs) return
|
|
297
|
+
|
|
298
|
+
if (files.length > this.#limits.maxFiles) {
|
|
299
|
+
this.#problem(
|
|
300
|
+
`The views tree has ${files.length} files, over the ${this.#limits.maxFiles} a snapshot allows; the current views keep serving.`,
|
|
301
|
+
)
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
const bytes = files.reduce((total, file) => total + file.size, 0)
|
|
305
|
+
if (bytes > this.#limits.maxBytes) {
|
|
306
|
+
this.#problem(
|
|
307
|
+
`The views tree is ${Math.round(bytes / 1024)} KB, over the ${Math.round(this.#limits.maxBytes / 1024)} KB a snapshot allows; the current views keep serving.`,
|
|
308
|
+
)
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.#gateHash = gateHash
|
|
313
|
+
await this.#materialise(files)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Copies the tree once, hashing the bytes it writes.
|
|
318
|
+
*
|
|
319
|
+
* One pass, so the name always describes the content: hashing first and
|
|
320
|
+
* copying second could name a directory after bytes it does not hold. The
|
|
321
|
+
* copy lands in a partial directory and is renamed into place, so no render
|
|
322
|
+
* can import a half-written tree.
|
|
323
|
+
*/
|
|
324
|
+
async #materialise(files: FileEntry[]): Promise<void> {
|
|
325
|
+
const partial = join(this.#cacheDir, `${PARTIAL}${crypto.randomUUID().slice(0, 8)}`)
|
|
326
|
+
const hasher = createHash('sha256')
|
|
327
|
+
try {
|
|
328
|
+
mkdirSync(partial, { recursive: true })
|
|
329
|
+
for (const file of files) {
|
|
330
|
+
const bytes = await Bun.file(file.absolute).bytes()
|
|
331
|
+
hasher.update(`${file.key}\0`)
|
|
332
|
+
hasher.update(bytes)
|
|
333
|
+
hasher.update('\n')
|
|
334
|
+
const target = join(partial, ...file.key.split('/'))
|
|
335
|
+
mkdirSync(join(target, '..'), { recursive: true })
|
|
336
|
+
writeFileSync(target, bytes)
|
|
337
|
+
}
|
|
338
|
+
const hash = hasher.digest('hex').slice(0, 16)
|
|
339
|
+
|
|
340
|
+
// Already rendering this exact tree: the copy was wasted but harmless.
|
|
341
|
+
if (hash === this.#current?.hash) return
|
|
342
|
+
|
|
343
|
+
// A deploy writing twenty files is not atomic, so a snapshot can catch a
|
|
344
|
+
// half-synced tree. If anything moved while copying, leave it for the next
|
|
345
|
+
// check rather than publish a generation nobody asked for.
|
|
346
|
+
const after = walk(this.#sourceDir)
|
|
347
|
+
if (!sameTree(files, after)) return
|
|
348
|
+
|
|
349
|
+
if (!this.#loaded.has(hash) && this.#loaded.size >= this.#limits.maxGenerations) {
|
|
350
|
+
this.#frozen = true
|
|
351
|
+
this.#problem(
|
|
352
|
+
`This node has loaded ${this.#loaded.size} view generations, its limit. The views it is serving keep serving and newer ones are refused; restart the node to pick them up.`,
|
|
353
|
+
)
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const dir = join(this.#cacheDir, hash)
|
|
358
|
+
if (existsSync(dir)) rmSync(partial, { recursive: true, force: true })
|
|
359
|
+
else renameSync(partial, dir)
|
|
360
|
+
|
|
361
|
+
const previous = this.#current?.hash
|
|
362
|
+
this.#current = { hash, dir }
|
|
363
|
+
this.#loaded.add(hash)
|
|
364
|
+
this.#swappedAt = this.#now()
|
|
365
|
+
this.#refused = undefined
|
|
366
|
+
if (this.#announced === hash) this.#eagerUntil = 0
|
|
367
|
+
this.#onSwap(hash, previous)
|
|
368
|
+
this.#evict(hash)
|
|
369
|
+
} finally {
|
|
370
|
+
rmSync(partial, { recursive: true, force: true })
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Drops the oldest generations on disk, newest kept.
|
|
376
|
+
*
|
|
377
|
+
* Deleting one is safe even while its modules are loaded — a module lives in
|
|
378
|
+
* the registry, not on disk — and a return to that hash re-creates the path,
|
|
379
|
+
* which `import()` answers from the registry without reading anything.
|
|
380
|
+
*/
|
|
381
|
+
#evict(current: string): void {
|
|
382
|
+
if (!Number.isFinite(this.#limits.keep)) return
|
|
383
|
+
const generations = readdirSync(this.#cacheDir)
|
|
384
|
+
.filter((name) => name !== current && !name.startsWith(PARTIAL))
|
|
385
|
+
.map((name) => ({ name, at: statSync(join(this.#cacheDir, name)).mtimeMs }))
|
|
386
|
+
.sort((a, b) => b.at - a.at)
|
|
387
|
+
for (const stale of generations.slice(this.#limits.keep))
|
|
388
|
+
rmSync(join(this.#cacheDir, stale.name), { recursive: true, force: true })
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function sameTree(before: FileEntry[], after: FileEntry[]): boolean {
|
|
393
|
+
if (before.length !== after.length) return false
|
|
394
|
+
return before.every((file, index) => {
|
|
395
|
+
const other = after[index] as FileEntry
|
|
396
|
+
return file.key === other.key && file.size === other.size && file.mtimeMs === other.mtimeMs
|
|
397
|
+
})
|
|
398
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL segment generation.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Umbraco's DefaultUrlSegmentProvider: the `umbracoUrlName` property wins
|
|
5
|
+
* if set, otherwise the name is slugified — diacritics folded, lowercased, and
|
|
6
|
+
* separated with hyphens.
|
|
7
|
+
*/
|
|
8
|
+
export const URL_NAME_ALIAS = 'umbracoUrlName'
|
|
9
|
+
/** Extra comma-separated addresses a page also answers on. */
|
|
10
|
+
export const URL_ALIAS_ALIAS = 'umbracoUrlAlias'
|
|
11
|
+
/** A content picker: visiting the page sends the visitor to the picked one. */
|
|
12
|
+
export const REDIRECT_ALIAS = 'umbracoRedirect'
|
|
13
|
+
/** Hides a page from navigation built with `visibleOnly`. */
|
|
14
|
+
export const NAVI_HIDE_ALIAS = 'umbracoNaviHide'
|
|
15
|
+
|
|
16
|
+
export function toUrlSegment(value: string): string {
|
|
17
|
+
return value
|
|
18
|
+
.normalize('NFKD')
|
|
19
|
+
.replace(/[̀-ͯ]/g, '')
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
22
|
+
.replace(/^-+|-+$/g, '')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function urlSegmentFor(name: string, urlNameOverride?: string | null): string {
|
|
26
|
+
const source = urlNameOverride && urlNameOverride.trim().length > 0 ? urlNameOverride : name
|
|
27
|
+
return toUrlSegment(source)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Joins segments into a site-root-relative path, always with a leading slash. */
|
|
31
|
+
export function joinPath(segments: readonly string[]): string {
|
|
32
|
+
const parts = segments.filter((segment) => segment.length > 0)
|
|
33
|
+
return parts.length === 0 ? '/' : `/${parts.join('/')}`
|
|
34
|
+
}
|