Skip to content

Commit b7cdd32

Browse files
committed
"Basic" template string system
1 parent 9ccbd89 commit b7cdd32

6 files changed

Lines changed: 332 additions & 12 deletions

File tree

backend/src/bot/common/discord/format.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DiscordRESTError, Member, User, type Uncached } from "oceanic.js";
1+
import { DiscordRESTError, type Uncached } from "oceanic.js";
22
import { fetchUserCachedSupressed } from "./cachedRequest.ts";
33
import { escapeMarkdown } from "./markdown.ts";
44

@@ -23,15 +23,17 @@ export async function formatUserBoldByID(id: string): Promise<string> {
2323
return formatUserBold(await fetchUserCachedSupressed(id));
2424
}
2525

26-
export function formatUser(user: User | Member | Uncached): string {
26+
type UserLike = { id: string; tag: string; } | Uncached;
27+
28+
export function formatUser(user: UserLike): string {
2729
return `${formatUserTag(user)} (<@${user.id}>)`;
2830
}
2931

30-
export function formatUserBold(user: User | Member | Uncached): string {
32+
export function formatUserBold(user: UserLike): string {
3133
return `**${formatUserTag(user)}** (<@${user.id}>)`;
3234
}
3335

34-
export function formatUserTag(user: User | Member | Uncached): string {
36+
export function formatUserTag(user: UserLike): string {
3537
if ("tag" in user)
3638
return escapeMarkdown(user.tag);
3739
else
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { formatUser, formatUserBold } from "../../bot/common/discord/format.ts";
2+
import { escapeMarkdown, makeMarkdownInlineCodeblock, makeMarkdownMultilineCodeblock, makeMarkdownQuote } from "../../bot/common/discord/markdown.ts";
3+
import { dateToUnixSeconds, humanizeDuration } from "../time.ts";
4+
import { DurationPresentationType, FormattingWrapper, ParameterType, TimestampPresentationType, UserPresentationType, type AnyParameterValue, type UserParameter } from "./index.ts";
5+
import { TokenType, type Token } from "./parsing.ts";
6+
7+
export function format(params: Record<string, AnyParameterValue>, tokens: Token[]): string {
8+
let result = "";
9+
10+
for (const token of tokens) {
11+
if (token.type === TokenType.Literal) {
12+
result += token.value;
13+
continue;
14+
}
15+
16+
if (!Object.hasOwn(params, token.parameter))
17+
throw new Error(`Missing params['${token.parameter}']`);
18+
19+
const value = params[token.parameter];
20+
let output: string;
21+
22+
switch (token.valueType) {
23+
case ParameterType.User:
24+
if (!(typeof value === "object"
25+
&& "id" in value && typeof value.id === "string"
26+
&& "tag" in value && typeof value.tag === "string")) {
27+
throw new Error(`params['${token.parameter}'] is not a user!`);
28+
}
29+
30+
output = formatUserParam(value, token.presentation);
31+
break;
32+
case ParameterType.Duration:
33+
if (typeof value !== "number")
34+
throw new Error(`params['${token.parameter}'] is not a number!`);
35+
36+
output = formatDurationParam(value, token.presentation);
37+
break;
38+
case ParameterType.Timestamp:
39+
if (!(value instanceof Date))
40+
throw new Error(`params['${token.parameter}'] is not a Date!`);
41+
42+
output = formatTimestampParam(value, token.presentation);
43+
break;
44+
case ParameterType.RawString:
45+
case ParameterType.MarkdownString:
46+
if (typeof value !== "string")
47+
throw new Error(`params['${token.parameter}'] is not a string!`);
48+
49+
output = value;
50+
break;
51+
}
52+
53+
switch (token.wrapper) {
54+
case FormattingWrapper.BlockQuote: output = makeMarkdownQuote(output); break;
55+
case FormattingWrapper.InlineCodeblock: output = makeMarkdownInlineCodeblock(output); break;
56+
case FormattingWrapper.MultilineCodeblock: output = makeMarkdownMultilineCodeblock(output); break;
57+
}
58+
59+
result += output;
60+
}
61+
62+
return result;
63+
64+
}
65+
66+
function formatUserParam(user: UserParameter, presentation: UserPresentationType): string {
67+
switch (presentation) {
68+
case UserPresentationType.TagMention: return formatUser(user);
69+
case UserPresentationType.TagMentionBold: return formatUserBold(user);
70+
case UserPresentationType.Tag: return escapeMarkdown(user.tag);
71+
case UserPresentationType.Mention: return `<@${user.id}>`;
72+
case UserPresentationType.ID: return user.id;
73+
case UserPresentationType.URL: return `https://discord.com/users/${user.id}`;
74+
}
75+
}
76+
77+
function formatDurationParam(duration: number, presentation: DurationPresentationType): string {
78+
switch (presentation) {
79+
case DurationPresentationType.Readable: return humanizeDuration(duration);
80+
case DurationPresentationType.Milliseconds: return duration.toString();
81+
case DurationPresentationType.Seconds: return Math.floor(duration / 1000).toString();
82+
}
83+
}
84+
85+
function formatTimestampParam(timestamp: Date, present: TimestampPresentationType): string {
86+
switch (present) {
87+
case TimestampPresentationType.DateTime: return `<t:${dateToUnixSeconds(timestamp)}:f>`;
88+
case TimestampPresentationType.DateTimeLong: return `<t:${dateToUnixSeconds(timestamp)}:F>`;
89+
case TimestampPresentationType.Time: return `<t:${dateToUnixSeconds(timestamp)}:t>`;
90+
case TimestampPresentationType.TimeLong: return `<t:${dateToUnixSeconds(timestamp)}:T>`;
91+
case TimestampPresentationType.Date: return `<t:${dateToUnixSeconds(timestamp)}:d>`;
92+
case TimestampPresentationType.DateLong: return `<t:${dateToUnixSeconds(timestamp)}:D>`;
93+
case TimestampPresentationType.Relative: return `<t:${dateToUnixSeconds(timestamp)}:R>`;
94+
case TimestampPresentationType.Unix: return timestamp.getTime().toString();
95+
case TimestampPresentationType.UnixSeconds: return dateToUnixSeconds(timestamp).toString();
96+
}
97+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { format } from "./formatting.ts";
2+
import { parseTemplateTokens, type Token } from "./parsing.ts";
3+
4+
export class Template<P extends Record<string, ParameterType>> {
5+
private _tokens: Token[];
6+
7+
public static parse<P extends Record<string, ParameterType>>(template: string, params: P): Template<P> | string {
8+
const tokens = parseTemplateTokens(template, params);
9+
10+
if (typeof tokens === "string")
11+
return tokens;
12+
13+
return new Template(tokens);
14+
}
15+
16+
private constructor(tokens: Token[]) {
17+
this._tokens = tokens;
18+
}
19+
20+
public format(params: { readonly [K in keyof P]: ParameterValue<P[K]> }): string {
21+
return format(params, this._tokens);
22+
}
23+
}
24+
25+
export const enum FormattingWrapper {
26+
BlockQuote,
27+
InlineCodeblock,
28+
MultilineCodeblock,
29+
}
30+
31+
export const enum ParameterType {
32+
User,
33+
Duration,
34+
Timestamp,
35+
RawString,
36+
MarkdownString,
37+
}
38+
39+
export const enum UserPresentationType {
40+
TagMention,
41+
TagMentionBold,
42+
Tag,
43+
Mention,
44+
ID,
45+
URL,
46+
}
47+
48+
export const enum DurationPresentationType {
49+
Readable,
50+
Seconds,
51+
Milliseconds,
52+
}
53+
54+
export const enum TimestampPresentationType {
55+
DateTime,
56+
DateTimeLong,
57+
Time,
58+
TimeLong,
59+
Date,
60+
DateLong,
61+
Relative,
62+
Unix,
63+
UnixSeconds,
64+
}
65+
66+
export type UserParameter = { id: string; tag: string; };
67+
68+
export type AnyParameterValue = ParameterValue<any>;
69+
70+
type ParameterValue<T extends ParameterType = any> =
71+
T extends ParameterType.User ? UserParameter :
72+
T extends ParameterType.RawString ? string :
73+
T extends ParameterType.MarkdownString ? string :
74+
T extends ParameterType.Duration ? number :
75+
T extends ParameterType.Timestamp ? Date :
76+
never;
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { DurationPresentationType, FormattingWrapper, ParameterType, TimestampPresentationType, UserPresentationType } from "./index.ts";
2+
3+
const FORMAT_PATTERN = /\{\{(?<wrapper>>|`|```)?(?<parameter>\w+)(?:#(?<presentation>\w+))?\}\}?/g;
4+
5+
interface FormatGroups {
6+
wrapper?: ">" | "`" | "```";
7+
parameter: string;
8+
presentation?: string;
9+
};
10+
11+
export function parseTemplateTokens(template: string, params: Record<string, ParameterType>): Token[] | string {
12+
const result: Token[] = [];
13+
14+
let match = FORMAT_PATTERN.exec(template);
15+
let literalStart = 0;
16+
17+
while (match !== null) {
18+
if (literalStart !== match.index) {
19+
result.push({
20+
type: TokenType.Literal,
21+
value: template.substring(literalStart, match.index),
22+
});
23+
}
24+
25+
const groups = match.groups as unknown as FormatGroups;
26+
const token = parseFormatToken(groups, params);
27+
28+
if (typeof token === "string")
29+
return token;
30+
else
31+
result.push(token);
32+
33+
literalStart = FORMAT_PATTERN.lastIndex;
34+
match = FORMAT_PATTERN.exec(template);
35+
}
36+
37+
if (literalStart !== template.length) {
38+
result.push({
39+
type: TokenType.Literal,
40+
value: template.substring(literalStart)
41+
});
42+
}
43+
44+
return result;
45+
}
46+
47+
function parseFormatToken(input: FormatGroups, params: Record<string, ParameterType>): FormatToken | string {
48+
if (!Object.hasOwn(params, input.parameter))
49+
return `No value named '${input.parameter}' exists`;
50+
51+
const valueType = params[input.parameter]!;
52+
const wrapper = parseWrapper(input.wrapper);
53+
54+
const result = { type: TokenType.Format, parameter: input.parameter, wrapper } as const;
55+
56+
switch (valueType) {
57+
case ParameterType.User: {
58+
const presentation = parseUserPresentation(input.presentation);
59+
60+
if (presentation === null)
61+
return `Invalid user presentation: '${input.presentation!}'`;
62+
63+
return { ...result, valueType, presentation };
64+
}
65+
case ParameterType.Duration: {
66+
const presentation = parseDurationPresentation(input.presentation);
67+
68+
if (presentation === null)
69+
return `Invalid duration presentation: '${input.presentation!}'`;
70+
71+
return { ...result, valueType, presentation };
72+
}
73+
case ParameterType.Timestamp:
74+
const presentation = parseTimestampPresentation(input.presentation);
75+
76+
if (presentation === null)
77+
return `Invalid timestamp presentation: '${input.presentation!}'`;
78+
79+
return { ...result, valueType, presentation };
80+
case ParameterType.RawString:
81+
case ParameterType.MarkdownString:
82+
return { ...result, valueType };
83+
}
84+
}
85+
86+
function parseWrapper(input: FormatGroups["wrapper"]): FormattingWrapper | null {
87+
switch (input) {
88+
case ">": return FormattingWrapper.BlockQuote;
89+
case "`": return FormattingWrapper.InlineCodeblock;
90+
case "```": return FormattingWrapper.MultilineCodeblock;
91+
case undefined: return null;
92+
}
93+
}
94+
95+
function parseUserPresentation(input: string | undefined): UserPresentationType | null {
96+
switch (input) {
97+
case "tag_mention": case undefined: return UserPresentationType.TagMention;
98+
case "tag_mention_bold": return UserPresentationType.TagMentionBold;
99+
case "tag": return UserPresentationType.Tag;
100+
case "mention": return UserPresentationType.Mention;
101+
case "id": return UserPresentationType.ID;
102+
case "url": return UserPresentationType.URL;
103+
default: return null;
104+
}
105+
}
106+
107+
function parseDurationPresentation(input: string | undefined): DurationPresentationType | null {
108+
switch (input) {
109+
case "readable": case undefined: return DurationPresentationType.Readable;
110+
case "seconds": case undefined: return DurationPresentationType.Seconds;
111+
case "milliseconds": case undefined: return DurationPresentationType.Milliseconds;
112+
default: return null;
113+
}
114+
}
115+
116+
function parseTimestampPresentation(input: string | undefined): TimestampPresentationType | null {
117+
switch (input) {
118+
case "date_time": case "f": case undefined: return TimestampPresentationType.DateTime;
119+
case "date_time_long": case "F": case undefined: return TimestampPresentationType.DateTimeLong;
120+
case "time": case "t": case undefined: return TimestampPresentationType.Time;
121+
case "time_long": case "T": case undefined: return TimestampPresentationType.TimeLong;
122+
case "date": case "d": case undefined: return TimestampPresentationType.Date;
123+
case "date_long": case "D": case undefined: return TimestampPresentationType.DateLong;
124+
case "relative": case "r": case "R": case undefined: return TimestampPresentationType.Relative;
125+
case "unix": case undefined: return TimestampPresentationType.Unix;
126+
case "unix_seconds": case undefined: return TimestampPresentationType.UnixSeconds;
127+
default: return null;
128+
}
129+
}
130+
131+
export type Token = LiteralToken | FormatToken;
132+
133+
export enum TokenType {
134+
Literal,
135+
Format,
136+
}
137+
138+
export interface LiteralToken {
139+
type: TokenType.Literal;
140+
value: string;
141+
}
142+
143+
export type FormatToken =
144+
{ type: TokenType.Format; parameter: string; }
145+
& (
146+
| { valueType: ParameterType.User; presentation: UserPresentationType; }
147+
| { valueType: ParameterType.Duration; presentation: DurationPresentationType; }
148+
| { valueType: ParameterType.Timestamp; presentation: TimestampPresentationType; }
149+
| { valueType: ParameterType.MarkdownString | ParameterType.RawString; }
150+
)
151+
& { wrapper: FormattingWrapper | null; };

backend/src/common/time.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ function humanizeDurationSegments(duration: number): string[] {
5656
if (duration >= WEEK)
5757
return result;
5858

59+
// TODO: looks like I forgot something
60+
5961
if (remainder >= HOUR) {
6062
result.push(Math.floor(remainder / HOUR) + " hours");
6163
remainder %= HOUR;

pnpm-lock.yaml

Lines changed: 0 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)