Skip to content

Commit a94aefc

Browse files
committed
feat(normalize): add normalize method compatible with the version from node:path
Sometimes used by us causing apps to rely on polyfilling node modules. Instead we can also simply provide this method. Added tests similar as the node package use to ensure compatibility. Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent 649d624 commit a94aefc

2 files changed

Lines changed: 62 additions & 0 deletions

File tree

lib/index.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,36 @@ export function isSamePath(path1: string, path2: string): boolean {
131131

132132
return path1 === path2
133133
}
134+
135+
/**
136+
* Normalizes the given path by removing leading, trailing and doubled slashes and also removing the dot sections.
137+
*
138+
* @param path - The path to normalize
139+
*/
140+
export function normalize(path: string): string {
141+
const sections = path.split('/')
142+
.filter((p, index, arr) => p !== '' || index === 0 || index === (arr.length - 1)) // remove double // but keep leading and trailing slash
143+
.filter((p) => p !== '.') // remove useless /./ sections
144+
145+
const sanitizedSections: string[] = []
146+
for (const section of sections) {
147+
const lastSection = sanitizedSections.at(-1)
148+
if (section === '..' && lastSection !== '..') {
149+
// if the current section is ".." for the parent, we remove the last section
150+
// But only if the last section is not also ".." which means that we are in a relative path outside of the root
151+
// Note that absolute paths like "/../../foo" are valid as they resolve to "/foo"
152+
if (lastSection === undefined) {
153+
// if there is no last section, we are at the root and we can't go up further
154+
// so we keep the ".." section as this is a relative path outside of the root
155+
sanitizedSections.push(section)
156+
} else if (lastSection !== '') {
157+
// only remove parent if its not the root (leading slash)
158+
sanitizedSections.pop()
159+
}
160+
} else {
161+
sanitizedSections.push(section)
162+
}
163+
}
164+
165+
return sanitizedSections.join('/')
166+
}

test/normalize.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: GPL-3.0-or-later
4+
*/
5+
6+
import { expect, test } from 'vitest'
7+
import { normalize } from '../lib/index.ts'
8+
9+
test('normalize', () => {
10+
expect(normalize('./fixtures///b/../b/c.js')).toBe('fixtures/b/c.js')
11+
expect(normalize('/foo/../../../bar')).toBe('/bar')
12+
expect(normalize('a//b//../b')).toBe('a/b')
13+
expect(normalize('a//b//./c')).toBe('a/b/c')
14+
expect(normalize('a//b//.')).toBe('a/b')
15+
expect(normalize('/a/b/c/../../../x/y/z')).toBe('/x/y/z')
16+
expect(normalize('///..//./foo/.//bar')).toBe('/foo/bar')
17+
expect(normalize('bar/foo../../')).toBe('bar/')
18+
expect(normalize('bar/foo../..')).toBe('bar')
19+
expect(normalize('bar/foo../../baz')).toBe('bar/baz')
20+
expect(normalize('bar/foo../')).toBe('bar/foo../')
21+
expect(normalize('bar/foo..')).toBe('bar/foo..')
22+
expect(normalize('../foo../../../bar')).toBe('../../bar')
23+
expect(normalize('../../.././../../../bar')).toBe('../../../../../../bar')
24+
expect(normalize('../../../foo/../../../bar')).toBe('../../../../../bar')
25+
expect(normalize('../../../foo/../../../bar/../../')).toBe('../../../../../../')
26+
expect(normalize('../foobar/barfoo/foo/../../../bar/../../')).toBe('../../')
27+
expect(normalize('../../../foobar/../../../bar/../../baz')).toBe('../../../../../../baz')
28+
expect(normalize('/../../../foobar/../../../bar/../../baz')).toBe('/baz')
29+
})

0 commit comments

Comments
 (0)