Create link parser and formatter

This commit is contained in:
Jorik Schellekens 2020-06-11 01:53:49 +01:00
parent 8b07e64a9b
commit 6325ea8328
3 changed files with 312 additions and 0 deletions

82
src/parser/parser.test.ts Normal file
View file

@ -0,0 +1,82 @@
import {
parseLink,
parsePermalink,
parseArgs,
verifiers,
discriminate,
toURI,
} from "./parser";
import { LinkDiscriminator } from "./types";
const curriedDiscriminate = (id: string) =>
discriminate(id, verifiers, LinkDiscriminator.ParseFailed);
it("types identifiers correctly", () => {
expect(curriedDiscriminate("@user:matrix.org")).toEqual(
LinkDiscriminator.UserId
);
expect(curriedDiscriminate("!room:matrix.org")).toEqual(
LinkDiscriminator.RoomId
);
expect(
curriedDiscriminate("!somewhere:example.org/$event:example.org")
).toEqual(LinkDiscriminator.Permalink);
expect(curriedDiscriminate("+group:matrix.org")).toEqual(
LinkDiscriminator.GroupId
);
expect(curriedDiscriminate("#alias:matrix.org")).toEqual(
LinkDiscriminator.Alias
);
});
it("types garbadge as such", () => {
expect(curriedDiscriminate("sdfa;fdlkja")).toEqual(
LinkDiscriminator.ParseFailed
);
expect(curriedDiscriminate("$event$matrix.org")).toEqual(
LinkDiscriminator.ParseFailed
);
expect(curriedDiscriminate("/user:matrix.org")).toEqual(
LinkDiscriminator.ParseFailed
);
});
it("parses vias", () => {
expect(
parseArgs("via=example.org&via=alt.example.org")
).toHaveProperty("vias", ["example.org", "alt.example.org"]);
});
it("parses sharer", () => {
expect(parseArgs("sharer=blah")).toHaveProperty("sharer", "blah");
});
it("parses random args", () => {
expect(parseArgs("via=qreqrqwer&banter=2342")).toHaveProperty(
"extras.banter",
["2342"]
);
});
it("parses permalinks", () => {
expect(parsePermalink("!somewhere:example.org/$event:example.org")).toEqual({
roomKind: LinkDiscriminator.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&uselesstag=useless";
const host = "matrix.org";
const prefix = host + "/#/";
const parse = parseLink(bigLink);
switch (parse.kind) {
case LinkDiscriminator.ParseFailed:
fail("Parse failed");
default:
expect(toURI(host, parse)).toEqual(prefix + bigLink);
}
});

175
src/parser/parser.ts Normal file
View file

@ -0,0 +1,175 @@
import _ from "lodash";
import {
LinkDiscriminator,
SafeLink,
Link,
LinkContent,
Arguments,
} from "./types";
/*
* Verifiers are regexes which will match valid
* identifiers to their type
*/
type Verifier<A> = [RegExp, A];
export const roomVerifiers: Verifier<
LinkDiscriminator.Alias | LinkDiscriminator.RoomId
>[] = [
[/^#([^\/:]+?):(.+)$/, LinkDiscriminator.Alias],
[/^!([^\/:]+?):(.+)$/, LinkDiscriminator.RoomId],
];
export const verifiers: Verifier<LinkDiscriminator>[] = [
[/^[\!#]([^\/:]+?):(.+?)\/\$([^\/:]+?):(.+?)$/, LinkDiscriminator.Permalink],
[/^@([^\/:]+?):(.+)$/, LinkDiscriminator.UserId],
[/^\+([^\/:]+?):(.+)$/, LinkDiscriminator.GroupId],
...roomVerifiers,
];
/*
* parseLink takes a striped hash link (without the '#/' prefix)
* and parses into a Link. If the parse failed the result will
* be ParseFailed
*/
export function parseLink(link: string): Link {
const [identifier, args] = link.split("?");
const kind = discriminate(
identifier,
verifiers,
LinkDiscriminator.ParseFailed
);
const { vias, sharer, extras } = parseArgs(args);
let parsedLink: LinkContent = {
identifier,
arguments: {
vias,
sharer,
extras,
},
originalLink: link,
};
if (kind === LinkDiscriminator.Permalink) {
const { roomKind, roomLink, eventId } = parsePermalink(identifier);
return {
kind,
...parsedLink,
roomKind,
roomLink,
eventId,
};
}
return {
kind,
...parsedLink,
};
}
/*
* Parses a permalink.
* Assumes the permalink is correct.
*/
export function parsePermalink(identifier: string) {
const [roomLink, eventId] = identifier.split("/");
const roomKind = discriminate(
roomLink,
roomVerifiers,
// This is hacky but we're assuming identifier is a valid permalink
LinkDiscriminator.Alias
);
return {
roomKind,
roomLink,
eventId,
};
}
/*
* descriminate applies the verifiers to the identifier and
* returns it's type
*/
export function discriminate<T, F>(
identifier: string,
verifiers: Verifier<T>[],
fail: F
): T | F {
if (identifier !== encodeURI(identifier)) {
return fail;
}
return verifiers.reduce<T | F>((discriminator, verifier) => {
if (discriminator !== fail) {
return discriminator;
}
if (identifier.match(verifier[0])) {
return verifier[1];
}
return discriminator;
}, fail);
}
/*
* parseArgs parses the <extra args> part of matrix.to links
*/
export function parseArgs(args: string): Arguments {
const parsedArgTuples = _.groupBy(
args
.split("&")
.map((x) => x.split("="))
.filter((x) => x.length == 2),
(arg) => {
return arg[0];
}
);
const parsedArgs = _.mapValues(parsedArgTuples, (arg) =>
arg.map((x) => x[1])
);
const { via, sharer, ...extras } = parsedArgs;
return {
vias: via,
sharer: (parsedArgs.sharer || [undefined])[0],
extras,
};
}
/*
* toURI converts a parsed link to uri. Typically it's recommended
* to show the original link if it existed but this is handy in the
* case where this was constructed.
*/
export function toURI(hostname: string, link: SafeLink): string {
const cleanHostname = hostname.trim().replace(/\/+$/, "");
switch (link.kind) {
case LinkDiscriminator.GroupId:
case LinkDiscriminator.UserId:
case LinkDiscriminator.RoomId:
case LinkDiscriminator.Alias:
case LinkDiscriminator.Permalink:
const uri = encodeURI(cleanHostname + "/#/" + link.identifier);
const vias = link.arguments.vias.map((s) => "via=" + s).join("&");
const sharer = link.arguments.sharer
? "sharer=" + link.arguments.sharer
: "";
const extras = _.map(link.arguments.extras, (vals, key) =>
vals.map((v) => key + "=" + v).join("&")
).join("&");
const args = [vias, sharer, extras].filter(Boolean).join("&");
if (args) {
return uri + "?" + args;
}
return uri;
}
}

55
src/parser/types.ts Normal file
View file

@ -0,0 +1,55 @@
import _ from "lodash";
export interface Arguments {
vias: string[];
// Either one of the enums or a custom link
sharer: string;
extras: { [key: string]: string[] };
}
export interface LinkContent {
identifier: string;
arguments: Arguments;
originalLink: string;
}
export enum LinkDiscriminator {
Alias = "ALIAS",
RoomId = "ROOM_ID",
UserId = "USER_ID",
Permalink = "PERMALINK",
GroupId = "GROUP_ID",
ParseFailed = "PARSE_FAILED",
}
export interface Alias extends LinkContent {
kind: LinkDiscriminator.Alias;
}
export interface RoomId extends LinkContent {
kind: LinkDiscriminator.RoomId;
}
export interface UserId extends LinkContent {
kind: LinkDiscriminator.UserId;
}
export interface GroupId extends LinkContent {
kind: LinkDiscriminator.GroupId;
}
export interface Permalink extends LinkContent {
kind: LinkDiscriminator.Permalink;
roomKind: LinkDiscriminator.RoomId | LinkDiscriminator.Alias;
roomLink: string;
eventId: string;
}
export interface ParseFailed {
kind: LinkDiscriminator.ParseFailed;
originalLink: string;
}
export type SafeLink = Alias | RoomId | UserId | Permalink | GroupId;
export type Link = SafeLink | ParseFailed;