Merge pull request #89 from matrix-org/matrixtwo/linkparser

Create link parser and formatter
This commit is contained in:
Jorik Schellekens 2020-08-10 12:35:48 +01:00 committed by GitHub
commit 9dd1008cb4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1203 additions and 1096 deletions

4
jest.config.js Normal file
View File

@ -0,0 +1,4 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

View File

@ -63,6 +63,7 @@
"@testing-library/user-event": "^7.1.2", "@testing-library/user-event": "^7.1.2",
"@types/classnames": "^2.2.10", "@types/classnames": "^2.2.10",
"@types/jest": "^24.0.0", "@types/jest": "^24.0.0",
"@types/lodash": "^4.14.159",
"@types/node": "^12.0.0", "@types/node": "^12.0.0",
"@types/react": "^16.9.0", "@types/react": "^16.9.0",
"@types/react-dom": "^16.9.0", "@types/react-dom": "^16.9.0",
@ -73,6 +74,7 @@
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"prettier": "^2.0.5", "prettier": "^2.0.5",
"storybook-addon-designs": "^5.4.0", "storybook-addon-designs": "^5.4.0",
"ts-jest": "^26.1.4",
"typescript": "~3.7.2" "typescript": "~3.7.2"
} }
} }

View File

@ -23,7 +23,8 @@ export default {
parameters: { parameters: {
design: { design: {
type: "figma", type: "figma",
url: "https://figma.com/file/WSXjCGc1k6FVI093qhlzOP/04-Recieving-share-link?node-id=59%3A1", url:
"https://figma.com/file/WSXjCGc1k6FVI093qhlzOP/04-Recieving-share-link?node-id=59%3A1",
}, },
}, },
}; };

View File

@ -25,7 +25,8 @@ export default {
parameters: { parameters: {
design: { design: {
type: "figma", type: "figma",
url: "https://figma.com/file/WSXjCGc1k6FVI093qhlzOP/04-Recieving-share-link?node-id=59%3A1", url:
"https://figma.com/file/WSXjCGc1k6FVI093qhlzOP/04-Recieving-share-link?node-id=59%3A1",
}, },
}, },
decorators: [withDesign], decorators: [withDesign],

View File

@ -23,7 +23,8 @@ export default {
parameters: { parameters: {
design: { design: {
type: "figma", type: "figma",
url: "https://figma.com/file/WSXjCGc1k6FVI093qhlzOP/04-Recieving-share-link?node-id=149%3A10756", url:
"https://figma.com/file/WSXjCGc1k6FVI093qhlzOP/04-Recieving-share-link?node-id=149%3A10756",
}, },
}, },
}; };

View File

@ -23,7 +23,8 @@ export default {
parameters: { parameters: {
design: { design: {
type: "figma", type: "figma",
url: "https://figma.com/file/WSXjCGc1k6FVI093qhlzOP/04-Recieving-share-link?node-id=143%3A5853", url:
"https://figma.com/file/WSXjCGc1k6FVI093qhlzOP/04-Recieving-share-link?node-id=143%3A5853",
}, },
}, },
}; };

64
src/parser/parser.test.ts Normal file
View File

@ -0,0 +1,64 @@
/* eslint-disable no-fallthrough */
import {
parseHash,
parsePermalink,
parseArgs,
verifiers,
identifyTypeFromRegex,
toURL,
} from "./parser";
import { LinkKind } from "./types";
const identifierType = (id: string): LinkKind =>
identifyTypeFromRegex(id, verifiers, LinkKind.ParseFailed);
it("types identifiers correctly", () => {
expect(identifierType("@user:matrix.org")).toEqual(LinkKind.UserId);
expect(identifierType("!room:matrix.org")).toEqual(LinkKind.RoomId);
expect(identifierType("!somewhere:example.org/$event:example.org")).toEqual(
LinkKind.Permalink
);
expect(identifierType("+group:matrix.org")).toEqual(LinkKind.GroupId);
expect(identifierType("#alias:matrix.org")).toEqual(LinkKind.Alias);
});
it("types garbage as such", () => {
expect(identifierType("sdfa;fdlkja")).toEqual(LinkKind.ParseFailed);
expect(identifierType("$event$matrix.org")).toEqual(LinkKind.ParseFailed);
expect(identifierType("/user:matrix.org")).toEqual(LinkKind.ParseFailed);
});
it("parses args correctly", () => {
expect(
parseArgs("via=example.org&via=alt.example.org")
).toHaveProperty("vias", ["example.org", "alt.example.org"]);
expect(parseArgs("sharer=blah")).toHaveProperty("sharer", "blah");
expect(parseArgs("client=blah.com")).toHaveProperty("client", "blah.com");
});
it("parses permalinks", () => {
expect(parsePermalink("!somewhere:example.org/$event:example.org")).toEqual(
{
roomKind: LinkKind.RoomId,
roomLink: "!somewhere:example.org",
eventId: "$event:example.org",
}
);
});
it("formats links correctly", () => {
const bigLink =
"!somewhere:example.org/$event:example.org?via=dfasdf&via=jfjafjaf";
const origin = "https://matrix.org";
const prefix = origin + "/#/";
const parse = parseHash(bigLink);
switch (parse.kind) {
case LinkKind.ParseFailed:
fail("Parse failed");
default:
expect(toURL(origin, parse).toString()).toEqual(prefix + bigLink);
}
});

163
src/parser/parser.ts Normal file
View File

@ -0,0 +1,163 @@
import forEach from "lodash/forEach";
import {
LinkKind,
SafeLink,
Link,
LinkContent,
Arguments,
Permalink,
} from "./types";
/*
* Verifiers are regexes which will match valid
* identifiers to their type. (This is a lie, they
* can return anything)
*/
type Verifier<A> = [RegExp, A];
export const roomVerifiers: Verifier<LinkKind.Alias | LinkKind.RoomId>[] = [
[/^#([^/:]+?):(.+)$/, LinkKind.Alias],
[/^!([^/:]+?):(.+)$/, LinkKind.RoomId],
];
export const verifiers: Verifier<LinkKind>[] = [
[/^[!#]([^/:]+?):(.+?)\/\$([^/:]+?):(.+?)$/, LinkKind.Permalink],
[/^@([^/:]+?):(.+)$/, LinkKind.UserId],
[/^\+([^/:]+?):(.+)$/, LinkKind.GroupId],
...roomVerifiers,
];
/*
* identifyTypeFromRegex applies the verifiers to the identifier and
* returns the identifier's type
*/
export function identifyTypeFromRegex<T, F>(
identifier: string,
verifiers: Verifier<T>[],
fail: F
): T | F {
if (identifier !== encodeURI(identifier)) {
return fail;
}
return verifiers.reduce<T | F>((kind, verifier) => {
if (kind !== fail) {
return kind;
}
if (identifier.match(verifier[0])) {
return verifier[1];
}
return kind;
}, fail);
}
/*
* Parses a permalink.
* Assumes the permalink is correct.
*/
export function parsePermalink(identifier: string): Permalink {
const [roomLink, eventId] = identifier.split("/");
const roomKind = identifyTypeFromRegex(
roomLink,
roomVerifiers,
// This is hacky but we're assuming identifier is a valid permalink
LinkKind.Alias
);
return {
roomKind,
roomLink,
eventId,
};
}
/*
* Repalces null with undefined
*/
function bottomExchange<T>(nullable: T | null): T | undefined {
if (nullable === null) return undefined;
return nullable;
}
/*
* parseArgs parses the <extra args> part of matrix.to links
*/
export function parseArgs(args: string): Arguments {
const params = new URLSearchParams(args);
return {
vias: params.getAll("via"),
client: bottomExchange(params.get("client")),
sharer: bottomExchange(params.get("sharer")),
};
}
/*
* parseLink takes a striped matrix.to hash link (without the '#/' prefix)
* and parses into a Link. If the parse failed the result will
* be ParseFailed
*/
export function parseHash(hash: string): Link {
const [identifier, args] = hash.split("?");
const kind = identifyTypeFromRegex(
identifier,
verifiers,
LinkKind.ParseFailed
);
const parsedLink: LinkContent = {
identifier,
arguments: parseArgs(args),
originalLink: hash,
};
if (kind === LinkKind.Permalink) {
const { roomKind, roomLink, eventId } = parsePermalink(identifier);
return {
kind,
...parsedLink,
roomKind,
roomLink,
eventId,
};
}
return {
kind,
...parsedLink,
};
}
/*
* toURI converts a Link to a url. It's recommended
* to use the original link instead of toURI if it existed.
* This is handy function in case the Link was constructed.
*/
export function toURL(origin: string, link: SafeLink): URL {
const params = new URLSearchParams();
const url = new URL(origin);
switch (link.kind) {
case LinkKind.GroupId:
case LinkKind.UserId:
case LinkKind.RoomId:
case LinkKind.Alias:
case LinkKind.Permalink:
forEach(link.arguments, (value, key) => {
if (value === undefined) {
// do nothing
} else if (key === "vias") {
(value as string[]).forEach((via) =>
params.append("via", via)
);
} else {
params.append(key, value.toString());
}
});
url.hash = `/${link.identifier}?${params.toString()}`;
}
return url;
}

54
src/parser/types.ts Normal file
View File

@ -0,0 +1,54 @@
export interface Arguments {
vias: string[];
// Schemeless http identifier
client?: string;
// MXID
sharer?: string;
}
export interface LinkContent {
identifier: string;
arguments: Arguments;
originalLink: string;
}
export enum LinkKind {
Alias = "ALIAS",
RoomId = "ROOM_ID",
UserId = "USER_ID",
Permalink = "PERMALINK",
GroupId = "GROUP_ID",
ParseFailed = "PARSE_FAILED",
}
export interface Alias extends LinkContent {
kind: LinkKind.Alias;
}
export interface RoomId extends LinkContent {
kind: LinkKind.RoomId;
}
export interface UserId extends LinkContent {
kind: LinkKind.UserId;
}
export interface GroupId extends LinkContent {
kind: LinkKind.GroupId;
}
export interface Permalink extends LinkContent {
kind: LinkKind.Permalink;
roomKind: LinkKind.RoomId | LinkKind.Alias;
roomLink: string;
eventId: string;
}
export interface ParseFailed {
kind: LinkKind.ParseFailed;
originalLink: string;
}
export type SafeLink = Alias | RoomId | UserId | Permalink | GroupId;
export type Link = SafeLink | ParseFailed;

View File

@ -16,6 +16,7 @@
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"strictNullChecks": true,
"noEmit": true, "noEmit": true,
"jsx": "react" "jsx": "react"
}, },

1999
yarn.lock

File diff suppressed because it is too large Load Diff