diff --git a/docs/data-model.md b/docs/data-model.md index 63f6e8d71..3be8996a1 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -86,6 +86,12 @@ flowchart LR `HTTPS://Pod.example:443/kortlekar-å` is `https://pod.example/kortlekar-%C3%A5/`. Text that's no http(s) address isn't an instance to attach (`notAnInstanceNoMeta`). + An instance with no title is named after its folder, with its + percent-encoding undone; a name whose escapes don't decode, such as + `caf%E9`, is shown as written (`lastPathSegment` in + [urls.ts](../packages/solid/src/urls.ts)). A registration that still + can't be read as an instance is left out, and the rest of the list + still loads. - **Each class of an instance's data** has a registration of its own, titled with the instance's name, for other apps ([examples below](#registrations)): diff --git a/packages/solid/src/mappers/instanceMapper.test.ts b/packages/solid/src/mappers/instanceMapper.test.ts index b08aed50d..e13c79faa 100644 --- a/packages/solid/src/mappers/instanceMapper.test.ts +++ b/packages/solid/src/mappers/instanceMapper.test.ts @@ -31,6 +31,14 @@ describe("toInstance", () => { name: "main", }); }); + + it.each([ + ["https://pod.example/solid-memo/caf%E9/", "caf%E9"], + ["https://pod.example/x%/", "x%"], + ["https://pod.example/%ZZ/", "%ZZ"], + ])("names an untitled %s by its segment as written when it does not decode", (url, name) => { + expect(toInstance({ containerUrl: url, title: null })).toEqual({ url, name }); + }); }); const META = "https://pod.example/solid-memo/main/meta.ttl#it"; diff --git a/packages/solid/src/solidInstanceRepository.test.ts b/packages/solid/src/solidInstanceRepository.test.ts index 017466ade..9df04e62b 100644 --- a/packages/solid/src/solidInstanceRepository.test.ts +++ b/packages/solid/src/solidInstanceRepository.test.ts @@ -115,6 +115,25 @@ describe("listInstances", () => { ]); }); + it("lists the others when one registration does not map", async () => { + vi.mocked(locateTypeIndexes).mockResolvedValue({ + fetch: indexFetch, + inPlaces: () => true, + privateIndexUrl: PRIVATE_INDEX, + publicIndexUrl: null, + }); + vi.mocked(readInstanceRegistrations).mockResolvedValueOnce([ + { containerUrl: CONTAINER, title: "Main" }, + { containerUrl: "not an address", title: null }, + { containerUrl: "https://alice.example/x%E9/", title: null }, + ]); + + await expect(makeRepository().listInstances(WEBID)).resolves.toEqual([ + { url: CONTAINER, name: "Main" }, + { url: "https://alice.example/x%E9/", name: "x%E9" }, + ]); + }); + it("skips missing indexes and tolerates unreadable ones", async () => { vi.mocked(locateTypeIndexes).mockResolvedValue({ fetch: indexFetch, @@ -508,14 +527,14 @@ describe("createInstance", () => { }); describe("attachInstance", () => { - function metaDataset(withTitle: boolean) { - let thing = buildThing(createThing({ url: `${CONTAINER}meta.ttl#it` })) + function metaDataset(withTitle: boolean, container = CONTAINER) { + let thing = buildThing(createThing({ url: `${container}meta.ttl#it` })) .addIri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type", SM.Instance) .addDatetime(DCTERMS.created, new Date("2026-09-21T10:00:00.000Z")); if (withTitle) { thing = thing.addStringNoLocale(DCTERMS.title, "Attached"); } - return setThing(datasetFrom(`${CONTAINER}meta.ttl`), thing.build()); + return setThing(datasetFrom(`${container}meta.ttl`), thing.build()); } it("registers an existing instance, and nothing else of it, and returns its name", async () => { @@ -558,6 +577,20 @@ describe("attachInstance", () => { ).resolves.toEqual({ url: CONTAINER, name: "main" }); }); + it("names an untitled instance by its folder as written when the folder does not decode", async () => { + const container = "https://alice.example/x%E9/"; + vi.mocked(readDocument).mockResolvedValue(readOf(metaDataset(false, container))); + vi.mocked(ensureTypeIndex).mockResolvedValue({ url: PRIVATE_INDEX, fetch: indexFetch }); + + await expect( + makeRepository().attachInstance({ + webId: WEBID, + instanceUrl: container, + registrationTarget: "private", + }), + ).resolves.toEqual({ url: container, name: "x%E9" }); + }); + it("rejects a container without a readable meta.ttl", async () => { vi.mocked(readDocument).mockRejectedValue(new Error("404")); diff --git a/packages/solid/src/solidInstanceRepository.ts b/packages/solid/src/solidInstanceRepository.ts index bb72ad659..52a928a61 100644 --- a/packages/solid/src/solidInstanceRepository.ts +++ b/packages/solid/src/solidInstanceRepository.ts @@ -89,7 +89,14 @@ export function createSolidInstanceRepository({ if (!byUrl.has(url)) byUrl.set(url, registration); } } - return [...byUrl.values()].map(toInstance); + // One registration that does not map is left out, not the list. + return [...byUrl.values()].flatMap((registration) => { + try { + return [toInstance(registration)]; + } catch { + return []; + } + }); }, async getRegistrationOptions(webId) { diff --git a/packages/solid/src/urls.test.ts b/packages/solid/src/urls.test.ts index 8ca486068..7f570d5ac 100644 --- a/packages/solid/src/urls.test.ts +++ b/packages/solid/src/urls.test.ts @@ -24,6 +24,14 @@ describe("lastPathSegment", () => { expect(lastPathSegment("https://pod.example/mem%C3%B3/")).toBe("memó"); }); + it.each([ + ["https://pod.example/solid-memo/caf%E9/", "caf%E9"], + ["https://pod.example/x%/", "x%"], + ["https://pod.example/%ZZ/", "%ZZ"], + ])("keeps %s's last segment as written when it does not decode", (url, segment) => { + expect(lastPathSegment(url)).toBe(segment); + }); + it("falls back to the input for a root URL", () => { expect(lastPathSegment("https://pod.example/")).toBe("https://pod.example/"); }); diff --git a/packages/solid/src/urls.ts b/packages/solid/src/urls.ts index e896adb77..f5936b840 100644 --- a/packages/solid/src/urls.ts +++ b/packages/solid/src/urls.ts @@ -4,9 +4,19 @@ export { ensureTrailingSlash } from "@solid-memo/domain/instanceLayout"; -/** Last path segment of a URL, e.g. "japanese" for ".../solid-memo/japanese/". */ +/** + * Last path segment of a URL, e.g. "japanese" for ".../solid-memo/japanese/", + * with its percent-encoding undone. A segment whose escapes do not decode + * (a lone `%`, `%ZZ`, or bytes that are not UTF-8 such as `%E9`) is + * returned as written: an address a pod holds may carry any of them. + */ export function lastPathSegment(url: string): string { const segments = new URL(url).pathname.split("/").filter((s) => s.length > 0); const last = segments[segments.length - 1]; - return last === undefined ? url : decodeURIComponent(last); + if (last === undefined) return url; + try { + return decodeURIComponent(last); + } catch { + return last; + } }