Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)):
Expand Down
8 changes: 8 additions & 0 deletions packages/solid/src/mappers/instanceMapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
39 changes: 36 additions & 3 deletions packages/solid/src/solidInstanceRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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"));

Expand Down
9 changes: 8 additions & 1 deletion packages/solid/src/solidInstanceRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions packages/solid/src/urls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/");
});
Expand Down
14 changes: 12 additions & 2 deletions packages/solid/src/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading