···11+# Hacking
22+33+How to develop for this project.
44+55+## Testing
66+77+```shell
88+pnpm -w run test
99+```
1010+1111+## Formatting
1212+1313+```shell
1414+pnpm -w run format
1515+```
1616+1717+## Building
1818+1919+```shell
2020+pnpm -r run build
2121+```
+21
LICENSE
···11+MIT License
22+33+Copyright (c) 2022 Spenser Black
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
···11+# Steamdown
22+33+A Markdown-like language that renders to Steam's markup language.
44+55+## Differences from Markdown
66+77+This language should be familiar to anyone who has used Markdown, but there are some key
88+differences. This section acts as a sort of "cheat sheet" for those who are already
99+familiar with Markdown and just want to know what's different.
1010+1111+### Added syntax
1212+1313+#### Spoilers
1414+1515+Some sites support this syntax, so it might be familiar to you, but it's not part of
1616+the original Markdown spec.
1717+1818+You can spoiler text by wrapping it in `>!` and `!<`.
1919+2020+##### Example
2121+2222+###### Input
2323+2424+```
2525+>!This is a spoiler!<
2626+```
2727+2828+###### Output
2929+3030+```
3131+[spoiler]This is a spoiler[/spoiler]
3232+```
3333+3434+#### Noparse
3535+3636+Steam has the `[noparse]` tag, which causes any text inside it to be rendered as plain
3737+text. Steamdown supports this in both an inline and block form.
3838+3939+##### Examples
4040+4141+###### Input
4242+4343+```
4444+With noparse {[i]} will not be converted to italics.
4545+4646+Steamdown will also treat {*text inside noparse tags*} as plain text.
4747+4848+You can {{ {nest} }} inline noparse spans.
4949+5050+{{{
5151+You can use noparse blocks for larger sections of text.
5252+}}}
5353+5454+{{{{
5555+{{{
5656+You can nest noparse blocks.
5757+}}}
5858+}}}}
5959+```
6060+6161+###### Output
6262+6363+```
6464+With noparse [noparse][i][/noparse] will not be converted to italics.
6565+6666+Steamdown will also treat [noparse]*text inside noparse tags*[/noparse] as plain text.
6767+6868+You can [noparse]{nest}[/noparse] inline noparse spans.
6969+7070+[noparse]
7171+You can use noparse blocks for larger sections of text.
7272+[/noparse]
7373+7474+[noparse]
7575+{{{
7676+You can nest noparse blocks.
7777+}}}
7878+[/noparse]
7979+```
8080+8181+### Changed syntax
8282+8383+#### Blockquotes
8484+8585+Some Markdown renders allow you to use `>text` (no space after the `>`) to create a
8686+blockquote. To avoid conflicts with spoiler text, a space is required after the `>` in
8787+Steamdown.
8888+8989+In Steamdown, quotes can also have an author, with an optional post ID, by adding
9090+`(author;post ID)` after the quote. Quotes are *not* continued on newlines to avoid
9191+syntax conflicts.
9292+9393+##### Examples
9494+9595+###### Input
9696+9797+```
9898+> quote without an author
9999+100100+> quote with an author
101101+(author)
102102+103103+> quote with an author and post ID
104104+(author;12345)
105105+106106+> quotes can continue
107107+> on multiple lines
108108+109109+> quotes cannot
110110+continue without another >
111111+```
112112+113113+###### Output
114114+115115+```
116116+[quote]
117117+quote without an author
118118+[/quote]
119119+120120+[quote=author]
121121+quote with an author
122122+[/quote]
123123+124124+[quote=author;12345]
125125+quote with an author and post ID
126126+[/quote]
127127+128128+[quote]
129129+quotes can continue
130130+on multiple lines
131131+[/quote]
132132+133133+[quote]
134134+quotes cannot
135135+[/quote]
136136+137137+continue without another >
138138+```
139139+140140+#### Tables
141141+142142+Steam does *not* support cell alignment. It does, however, support `equalcells=1` and
143143+`noborder=1`, but apparently not for normal users. For `noborder=1`, use spaces instead
144144+of `-` in the alignment row. For `equalcells=1`, use `:---:` in the alignment row, as
145145+if you were centering a column in normal Markdown. "Alignment row" isn't really accurate
146146+for Steamdown, though, so it should perhaps be called the "attribute row".
147147+148148+##### Examples
149149+150150+In the following examples, it should be noted that the "Attribute row" affects the
151151+*entire table,* not just a column. For this reason, the first cell in an attribute row
152152+takes precedence, and the following only need to be the same for clearer plain-text.
153153+154154+###### "Plain" table
155155+156156+```
157157+| one | two |
158158+| --- | --- |
159159+| a | b |
160160+```
161161+162162+###### `noborder=1` table
163163+164164+```
165165+| one | two |
166166+| | |
167167+| a | b |
168168+```
169169+170170+**NOTE:** Omitting the left and right `|` was considered as a better visual
171171+representation of no borders, but was decided against because it could cause issues with
172172+tables that are 1 cell wide.
173173+174174+###### `equalcells=1` table
175175+176176+```
177177+| one | two |
178178+| :-: | :-: |
179179+| a | b |
180180+```
181181+182182+###### Both `noborder=1` and `equalcells=1` table
183183+184184+```
185185+| one | two |
186186+| : : | : : |
187187+| a | b |
188188+```
189189+190190+### Removed syntax
191191+192192+#### Images (``)
193193+194194+Steam doesn't have an equivalent (the closest is `[url=link]text[/url]`), so images
195195+are not supported. Consider using either links `[alt text](url)` or the plain-text link
196196+instead.
197197+198198+#### Inline code (`` `code` ``)
199199+200200+Steam seems to render all `[code]` tags as blocks, so inline code is not supported.
201201+Consider using a code block instead.
···11+import * as nodes from "../../nodes";
22+import { Parser } from "../types";
33+import { makeWrappedTextParser } from "./util";
44+55+/**
66+ * Parser for bold italics.
77+ *
88+ * HACK This is a hack to make it easier to parse italics nested in bold (or is it bold nested in italics?).
99+ */
1010+export const boldItalics = makeWrappedTextParser<nodes.BoldItalics>(
1111+ "***",
1212+ "bold-italics",
1313+) satisfies Parser<nodes.BoldItalics>;
+11
packages/steamdown/src/parser/inline/bold.ts
···11+import * as nodes from "../../nodes";
22+import { Parser } from "../types";
33+import { makeWrappedTextParser } from "./util";
44+55+/**
66+ * Parser for a bold node.
77+ */
88+export const bold = makeWrappedTextParser<nodes.Bold>(
99+ "**",
1010+ "bold",
1111+) satisfies Parser<nodes.Bold>;
···11+import { useParsers, parse, addParser } from "./parse";
22+import { noparse } from "./noparse";
33+import { url } from "./url";
44+import { spoiler } from "./spoiler";
55+import { boldItalics } from "./bold-italics";
66+import { bold } from "./bold";
77+import { italics } from "./italics";
88+import { underline } from "./underline";
99+import { strike } from "./strike";
1010+import { escaped } from "./escaped";
1111+import { text } from "./text";
1212+1313+[
1414+ noparse,
1515+ url,
1616+ spoiler,
1717+ boldItalics,
1818+ bold,
1919+ italics,
2020+ underline,
2121+ strike,
2222+ escaped,
2323+ text,
2424+].forEach((parser) => {
2525+ addParser(parser);
2626+});
2727+2828+export { useParsers, parse };
+11
packages/steamdown/src/parser/inline/italics.ts
···11+import * as nodes from "../../nodes";
22+import { Parser } from "../types";
33+import { makeWrappedTextParser } from "./util";
44+55+/**
66+ * Parser for an italics node.
77+ */
88+export const italics = makeWrappedTextParser<nodes.Italics>(
99+ "*",
1010+ "italics",
1111+) satisfies Parser<nodes.Italics>;
+41
packages/steamdown/src/parser/inline/noparse.ts
···11+import * as nodes from "../../nodes";
22+import { UnreachableError, ParseError } from "../errors";
33+import escapeRegExp from "escape-string-regexp";
44+import { Parser } from "../types";
55+66+/**
77+ * Parser for noparse spans.
88+ */
99+export const noparse = {
1010+ hint: (text: string) => text.startsWith("{"),
1111+ parse: (text: string): [nodes.NoparseSpan, remainder: string] => {
1212+ const openingMatch = /^\{+/.exec(text);
1313+1414+ if (!openingMatch) {
1515+ throw new UnreachableError("noparse span must start with {");
1616+ }
1717+ const opening = openingMatch[0];
1818+ const closing = new RegExp(`${escapeRegExp("}".repeat(opening.length))}`);
1919+2020+ const closingIndex = closing.exec(text)?.index;
2121+ if (closingIndex == null) {
2222+ throw new ParseError("noparse span must be closed");
2323+ }
2424+2525+ let innerText = text.slice(opening.length, closingIndex);
2626+2727+ // If innerText is surrounded by whitespace, remove it.
2828+ if (innerText.startsWith(" ") && innerText.endsWith(" ")) {
2929+ innerText = innerText.trim();
3030+ }
3131+3232+ const remainder = text.slice(closingIndex + opening.length);
3333+3434+ const node: nodes.NoparseSpan = {
3535+ type: "noparse-span",
3636+ text: innerText,
3737+ };
3838+3939+ return [node, remainder];
4040+ },
4141+} satisfies Parser<nodes.NoparseSpan>;
+48
packages/steamdown/src/parser/inline/parse.ts
···11+import * as nodes from "../../nodes";
22+import { firstSuccessfulParse } from "../util";
33+import { UnreachableError } from "../errors";
44+import { InlineParser } from "../types";
55+66+const inlineParsers: InlineParser[] = [];
77+88+export const addParser = (parser: InlineParser, index?: number) => {
99+ if (index != null) {
1010+ inlineParsers.splice(index, 0, parser);
1111+ } else {
1212+ inlineParsers.push(parser);
1313+ }
1414+};
1515+1616+export const useParsers = (): InlineParser[] => inlineParsers;
1717+1818+/**
1919+ * Parses text into inline nodes.
2020+ */
2121+export const parse = (text: string): nodes.Inline[] => {
2222+ let parsedText = text;
2323+ const nodes: nodes.Inline[] = [];
2424+2525+ while (parsedText.length > 0) {
2626+ const parsed = firstSuccessfulParse(inlineParsers, parsedText);
2727+2828+ if (!parsed) {
2929+ throw new UnreachableError();
3030+ }
3131+3232+ const [node, remainder] = parsed;
3333+3434+ parsedText = remainder;
3535+3636+ // HACK If the last node is a text node and this node is a text node,
3737+ // merge them together.
3838+ // NOTE undefined to handle the very start of iteration.
3939+ const lastNode: nodes.Inline | undefined = nodes[nodes.length - 1];
4040+ if (lastNode && lastNode.type === "text" && node.type === "text") {
4141+ lastNode.text += node.text;
4242+ } else {
4343+ nodes.push(node);
4444+ }
4545+ }
4646+4747+ return nodes;
4848+};
+11
packages/steamdown/src/parser/inline/spoiler.ts
···11+import * as nodes from "../../nodes";
22+import { Parser } from "../types";
33+import { makeWrappedTextParser } from "./util";
44+55+/**
66+ * Parser for a spoiler.
77+ */
88+export const spoiler = makeWrappedTextParser<nodes.Spoiler>(
99+ [">!", "!<"],
1010+ "spoiler",
1111+) satisfies Parser<nodes.Spoiler>;
+36
packages/steamdown/src/parser/inline/strike.ts
···11+import * as nodes from "../../nodes";
22+import { ParseError } from "../errors";
33+import { Parser } from "../types";
44+import { variableLengthInlineHelper } from "./util";
55+import { parse } from "./parse";
66+77+const helper = variableLengthInlineHelper("~");
88+99+/**
1010+ * Parser for a strike node.
1111+ */
1212+export const strike = {
1313+ hint: (text: string) => text.startsWith("~"),
1414+ parse: (text: string): [nodes.Strike, remainder: string] => {
1515+ const result = helper(text);
1616+ switch (result) {
1717+ case "no match":
1818+ throw new ParseError("strike must start with ~");
1919+ case "not closed":
2020+ throw new ParseError("strike must be closed");
2121+ }
2222+ const { wrapper, text: innerText } = result;
2323+ const consumedCharCount = wrapper.length + innerText.length + wrapper.length;
2424+ if ([innerText[0], innerText[innerText.length - 1]].some((s) => /\s/.test(s))) {
2525+ throw new ParseError("strike cannot start or end with whitespace");
2626+ }
2727+ const nodes = parse(innerText);
2828+2929+ const node: nodes.Strike = {
3030+ type: "strike",
3131+ nodes,
3232+ };
3333+ const remainder = text.slice(consumedCharCount);
3434+ return [node, remainder];
3535+ },
3636+} satisfies Parser<nodes.Strike>;
+27
packages/steamdown/src/parser/inline/text.ts
···11+import * as nodes from "../../nodes";
22+import { Parser } from "../types";
33+44+/**
55+ * Parser for a text node. This should never fail to parse.
66+ */
77+export const text = {
88+ hint: () => true,
99+ parse: (text: string): [nodes.Text, remainder: string] => {
1010+ let remainder = "";
1111+ // NOTE End on special chars to allow for parsing of other nodes, but only if that
1212+ // special char is not the first character.
1313+ const end = /[\\*_~{\[]/.exec(text);
1414+1515+ if (end && end.index > 0) {
1616+ remainder = text.slice(end.index);
1717+ text = text.slice(0, end.index);
1818+ }
1919+2020+ const node: nodes.Text = {
2121+ type: "text",
2222+ text,
2323+ };
2424+2525+ return [node, remainder];
2626+ },
2727+} satisfies Parser<nodes.Text>;
+11
packages/steamdown/src/parser/inline/underline.ts
···11+import * as nodes from "../../nodes";
22+import { Parser } from "../types";
33+import { makeWrappedTextParser } from "./util";
44+55+/**
66+ * Parser for an underline node.
77+ */
88+export const underline = makeWrappedTextParser<nodes.Underline>(
99+ "_",
1010+ "underline",
1111+) satisfies Parser<nodes.Underline>;
+42
packages/steamdown/src/parser/inline/url.ts
···11+import * as nodes from "../../nodes";
22+import { UnreachableError, ParseError } from "../errors";
33+import escapeRegExp from "escape-string-regexp";
44+import { Parser } from "../types";
55+import { parse } from "./parse";
66+77+/**
88+ * Parser for url.
99+ */
1010+export const url = {
1111+ hint: (text: string) => text.startsWith("["),
1212+ parse: (text: string): [nodes.Url, remainder: string] => {
1313+ // TODO Break this up into something more readable.
1414+ const match =
1515+ /^\[((?:[^\]]|\\\])+)(?<!\\)\](?:(?:\(([^)\n]+)\))|\[((?:[^\]\n]|\\\])+)\])?/.exec(
1616+ text,
1717+ );
1818+1919+ if (!match) {
2020+ throw new ParseError("invalid url");
2121+ }
2222+2323+ const [all, content, link, id] = match;
2424+ const remainder = text.slice(all.length);
2525+ const nodes = parse(content);
2626+2727+ const node: nodes.Url =
2828+ link != null
2929+ ? {
3030+ type: "link-url",
3131+ link,
3232+ nodes,
3333+ }
3434+ : {
3535+ type: "id-url",
3636+ id: id ?? content,
3737+ nodes,
3838+ };
3939+4040+ return [node, remainder];
4141+ },
4242+} satisfies Parser<nodes.Url>;
+97
packages/steamdown/src/parser/inline/util.ts
···11+import * as nodes from "../../nodes";
22+import { ParseError, UnreachableError } from "../errors";
33+import escapeRegExp from "escape-string-regexp";
44+import { parse } from "./parse";
55+66+/**
77+ * Helper for `makeWrappedTextParser`.
88+ */
99+export type WrappedNode = Exclude<nodes.Inline, nodes.Text>;
1010+1111+/**
1212+ * Creates a parser for wrapped text.
1313+ *
1414+ * For example, `*foo*` is `foo` wrapped in `*`.
1515+ */
1616+export const makeWrappedTextParser = <N extends WrappedNode>(
1717+ wrapper: string | [string, string],
1818+ type: N["type"],
1919+) => {
2020+ const wrappers = typeof wrapper === "string" ? [wrapper, wrapper] : wrapper;
2121+ const [startWrapper, endWrapper] = wrappers;
2222+ const endRegex = new RegExp(`(?<!\\\\)${escapeRegExp(endWrapper)}`);
2323+ return {
2424+ hint: (text: string) => text.startsWith(startWrapper),
2525+ parse: (text: string): [N, remainder: string] => {
2626+ text = text.slice(wrapper.length);
2727+2828+ const endMatch = endRegex.exec(text);
2929+ if (!endMatch) {
3030+ throw new ParseError(`${type} must be closed`);
3131+ }
3232+ const innerEndIndex = endMatch.index;
3333+3434+ const innerText = text.slice(0, endMatch.index);
3535+3636+ if (innerText.length === 0) {
3737+ throw new ParseError(`${type} must have content`);
3838+ }
3939+4040+ if ([innerText[0], innerText[innerText.length - 1]].some((s) => /\s/.test(s))) {
4141+ throw new ParseError(`${type} cannot start or end with whitespace`);
4242+ }
4343+4444+ const remainder = text.slice(innerEndIndex + endWrapper.length);
4545+4646+ const nodes = parse(innerText);
4747+4848+ const node = {
4949+ type,
5050+ nodes,
5151+ };
5252+5353+ return [node as N, remainder];
5454+ },
5555+ };
5656+};
5757+5858+/**
5959+ * Creates a helper for wrapped text for inline formatting, where the wrapper can be variable length.
6060+ *
6161+ * For example, both `~foo~` and `~~foo~~` would be `[strike]foo[/strike]`.
6262+ *
6363+ * This will return a helper that returns the text used for wrapping (`~~`) and the wrapped text.
6464+ * It will not perform much validation besides asserting that the text is wrapped.
6565+ *
6666+ * # Example
6767+ *
6868+ * ```typescript
6969+ * const helper = variableLengthInlineHelper("~");
7070+ * helper("foo ~~bar~~ baz"); // { wrapper: "~~", text: "bar" }
7171+ * ```
7272+ */
7373+export const variableLengthInlineHelper = (wrapperChar: string) => {
7474+ if (wrapperChar.length !== 1) {
7575+ throw new UnreachableError("wrapperChar must be a single character");
7676+ }
7777+ const regex = new RegExp(`^(?:(?!<\\\\)${escapeRegExp(wrapperChar)})+`);
7878+7979+ return (text: string) => {
8080+ const wrapperMatch = regex.exec(text);
8181+ if (!wrapperMatch) {
8282+ return "no match";
8383+ }
8484+ const wrapper = wrapperMatch[0];
8585+ const endRegex = new RegExp(`(?<!\\\\)${escapeRegExp(wrapper)}`);
8686+ text = text.slice(wrapper.length);
8787+ const endMatch = endRegex.exec(text);
8888+8989+ if (!endMatch) {
9090+ return "not closed";
9191+ }
9292+9393+ text = text.slice(0, endMatch.index);
9494+9595+ return { wrapper, text };
9696+ };
9797+};
+28
packages/steamdown/src/parser/types.ts
···11+import * as nodes from "../nodes";
22+import type { Context } from "../context";
33+44+/**
55+ * A parser for the syntax.
66+ */
77+export interface Parser<N extends nodes.Node> {
88+ /**
99+ * Hints if the parser can parse the given syntax.
1010+ * This doesn't guarantee that the text can be parsed if
1111+ * it returns `true`, but if it returns `false` it can't
1212+ * be parsed.
1313+ */
1414+ hint(text: string): boolean;
1515+ /**
1616+ * Parses the given syntax, returning the root node of the
1717+ * syntax tree and the remaining text. Can optionally mutate context.
1818+ */
1919+ parse(text: string): [node: N, remainder: string];
2020+}
2121+2222+export type InlineParser = Parser<nodes.Inline>;
2323+export type BlockParser = Parser<nodes.Block>;
2424+2525+export type Parsed = {
2626+ tree: nodes.Root;
2727+ context: Context;
2828+};
+37
packages/steamdown/src/parser/util.ts
···11+import * as nodes from "../nodes";
22+import { Parser } from "./types";
33+import { ParseError } from "./errors";
44+55+/**
66+ * Returns the first successful parse from the given parsers.
77+ */
88+export const firstSuccessfulParse = <N extends nodes.Node>(
99+ parsers: Parser<N>[],
1010+ text: string,
1111+): [N, remainder: string] | null => {
1212+ for (const parser of parsers) {
1313+ if (parser.hint(text)) {
1414+ try {
1515+ return parser.parse(text);
1616+ } catch (error) {
1717+ if (!(error instanceof ParseError)) {
1818+ throw error;
1919+ }
2020+ }
2121+ }
2222+ }
2323+ return null;
2424+};
2525+2626+export class Memoizer<T, U> extends Map<T, U> {
2727+ /**
2828+ * Returns the value associated with the key, or creates a new value using the
2929+ * given function and associates it with the key.
3030+ */
3131+ public getOrCreate(key: T, create: () => U): U {
3232+ if (!this.has(key)) {
3333+ this.set(key, create());
3434+ }
3535+ return this.get(key)!;
3636+ }
3737+}