initial commit

This commit is contained in:
jae kaplan
2025-10-17 17:02:59 -04:00
commit 732e0c4db6
29 changed files with 4323 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import React, { FunctionComponent } from "react";
export const CustomEmoji: FunctionComponent<{
name: string;
url: string;
}> = React.memo(({ name = "missing", url = "" }) => {
return (
<img
src={url}
alt={`:${name}:`}
title={`:${name}:`}
className="m-0 inline-block aspect-square object-contain align-middle"
style={{
height: "var(--emoji-scale, 1em)",
}}
/>
);
});
CustomEmoji.displayName = "CustomEmoji";

View File

@@ -0,0 +1,41 @@
import React, { FunctionComponent, ReactElement, useContext } from "react";
import { z } from "zod";
export const InfoBoxLevel = z.enum([
"info",
"warning",
"done",
"post-box-info",
"post-box-warning",
]);
export type InfoBoxLevel = z.infer<typeof InfoBoxLevel>;
type InfoBoxProps = {
level: InfoBoxLevel;
};
export const InfoBox: FunctionComponent<
React.PropsWithChildren<InfoBoxProps>
> = ({ level, children }) => {
let bgClasses: string;
switch (level) {
case "info":
bgClasses = "co-info-box co-info";
break;
case "warning":
bgClasses = "co-info-box co-warning";
break;
case "done":
bgClasses = "co-info-box co-done";
break;
case "post-box-info":
bgClasses = "co-info-box co-post-info";
break;
case "post-box-warning":
bgClasses = "co-info-box co-post-warning";
break;
}
return <div className={bgClasses}>{children}</div>;
};

View File

@@ -0,0 +1,24 @@
import { ProjectHandle } from "../types/ids";
import React, { FunctionComponent } from "react";
export const Mention: FunctionComponent<{ handle: ProjectHandle }> = ({
handle,
}) => {
return (
<a
data-testid="mention"
href={`https://cohost.org/${handle}`}
className="!font-bold !no-underline hover:!underline"
>
@{handle}
</a>
);
};
/**
* Default props included because Mention is used outside of typescript and we
* need an easy way to see when it's fucked instead of just crashing
*/
Mention.defaultProps = {
handle: "ERROR" as ProjectHandle,
};

2
src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./lib/post-rendering";
export * from "./lib/other-rendering";

107
src/lib/emoji.ts Normal file
View File

@@ -0,0 +1,107 @@
import path from "path";
import { SKIP } from "unist-util-visit";
import { processMatches } from "./unified-processors";
import type { Plugin, Compiler } from "unified";
import { Element, Text, type Root } from "hast";
const EMOJI_REGEX = /:[a-zA-Z\d-_]+:/gims;
export type CustomEmoji = {
id: string;
name: string;
keywords: string[];
skins: { src: string }[];
native?: undefined;
// added by the library; we don't need to set it
shortcodes?: string;
};
type CustomEmojiCategory = {
id: string;
name: string;
emojis: CustomEmoji[];
};
export type CustomEmojiSet = CustomEmojiCategory[];
type NativeEmoji = {
id: string;
keywords: string[];
name: string;
native: string;
shortcodes: string;
unified: string;
};
export type Emoji = NativeEmoji | CustomEmoji;
export const customEmoji: CustomEmoji[] = [];
export const cohostPlusCustomEmoji: CustomEmoji[] = [];
export const indexableCustomEmoji = new Map<string, CustomEmoji>(
customEmoji.reduce<[string, CustomEmoji][]>((collector, emoji) => {
return [...collector, [emoji.name, emoji]];
}, [])
);
export const indexableCohostPlusCustomEmoji = new Map<string, CustomEmoji>(
cohostPlusCustomEmoji.reduce<[string, CustomEmoji][]>((collector, emoji) => {
return [...collector, [emoji.name, emoji]];
}, [])
);
type ParseOptions = {
cohostPlus: boolean;
};
export const parseEmoji = (options: ParseOptions) => {
const compiler: Compiler<Root> = processMatches(
EMOJI_REGEX,
(matches, splits, node, index, parent) => {
const els = splits.reduce<Array<Element | Text>>(
(collector, curr, index) => {
const currNode: Text = {
type: "text",
value: curr,
};
const pending = [...collector, currNode];
if (index < matches.length) {
const emojiName = matches[index].slice(
1,
matches[index].length - 1
);
let emoji = indexableCustomEmoji.get(emojiName);
if (!emoji && options.cohostPlus) {
emoji = indexableCohostPlusCustomEmoji.get(emojiName);
}
if (emoji) {
pending.push({
type: "element",
tagName: "CustomEmoji",
properties: {
name: emoji.name,
url: emoji.skins[0].src,
},
children: [],
} as Element);
} else {
pending.push({
type: "text",
value: matches[index],
});
}
}
return pending;
},
[] as Array<Element | Text>
);
parent.children.splice(index, 1, ...els);
// skip over all the new elements we just created
return [SKIP, index + els.length];
}
);
return compiler;
};

View File

@@ -0,0 +1,89 @@
// Adapted from https://github.com/twitter/twitter-text
function regexSupplant(
regex: RegExp | string,
map: Record<string, string | RegExp>,
flags = ""
) {
if (typeof regex !== "string") {
if (regex.global && flags.indexOf("g") < 0) {
flags += "g";
}
if (regex.ignoreCase && flags.indexOf("i") < 0) {
flags += "i";
}
if (regex.multiline && flags.indexOf("m") < 0) {
flags += "m";
}
regex = regex.source;
}
return new RegExp(
regex.replace(/#\{(\w+)\}/g, function (match, name: string) {
let newRegex = map[name] || "";
if (typeof newRegex !== "string") {
newRegex = newRegex.source;
}
return newRegex;
}),
flags
);
}
const latinAccentChars =
/\xC0-\xD6\xD8-\xF6\xF8-\xFF\u0100-\u024F\u0253\u0254\u0256\u0257\u0259\u025B\u0263\u0268\u026F\u0272\u0289\u028B\u02BB\u0300-\u036F\u1E00-\u1EFF/;
const atSigns = /[@]/;
const validMentionPrecedingChars =
/(?:^|[^a-zA-Z0-9_!#$%&*@\\/]|(?:^|[^a-zA-Z0-9_+~.-\\/]))/;
const validMention = regexSupplant(
"(#{validMentionPrecedingChars})" + // $1: Preceding character
"(#{atSigns})" + // $2: At mark
"([a-zA-Z0-9-]{3,})", // $3: handle
{ validMentionPrecedingChars, atSigns },
"g"
);
const endMentionMatch = regexSupplant(
/^(?:#{atSigns}|[#{latinAccentChars}]|:\/\/)/,
{ atSigns, latinAccentChars }
);
type MentionToken = {
handle: string;
indices: [startPosition: number, endPosition: number];
};
export function extractMentions(text: string): MentionToken[] {
if (!text.match(atSigns)) {
return [];
}
const possibleNames: MentionToken[] = [];
text.replace(
validMention,
function (
match,
before: string,
atSign: string,
handle: string,
offset: number,
chunk: string
) {
const after = chunk.slice(offset + match.length);
if (!after.match(endMentionMatch)) {
const startPosition = offset + before.length;
const endPosition = startPosition + handle.length + 1;
possibleNames.push({
handle,
indices: [startPosition, endPosition],
});
}
return "";
}
);
return possibleNames;
}

139
src/lib/other-rendering.ts Normal file
View File

@@ -0,0 +1,139 @@
/**
* For comments, page descriptions, etc. Everything that accepts markdown and
* isn't a post.
*/
import { CustomEmoji } from "../components/custom-emoji";
import { compile } from "html-to-text";
import { createElement, Fragment } from "react";
import rehypeExternalLinks from "rehype-external-links";
import rehypeReact from "rehype-react";
import rehypeSanitize from "rehype-sanitize";
import rehypeStringify from "rehype-stringify";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
import { Mention } from "../components/mention";
import { parseEmoji } from "./emoji";
import { chooseAgeRuleset } from "./sanitize";
import { MAX_GFM_LINES, RenderingOptions } from "./shared-types";
import {
cleanUpFootnotes,
convertMentions,
copyImgAltToTitle,
} from "./unified-processors";
import remarkBreaks from "remark-breaks";
import _ from "lodash";
const convert = compile({
wordwrap: false,
});
/**
* Used in places like comments, page descriptions, etc. places we don't want to
* support arbitrary HTML
* @returns
*/
const markdownRenderStackNoHTML = (
postDate: Date,
lineLength: number,
options: RenderingOptions
) => {
let stack = unified().use(remarkParse);
const ruleset = chooseAgeRuleset(postDate);
if (ruleset.singleLineBreaks) {
stack = stack.use(remarkBreaks);
}
const externalRel = ["nofollow"];
if (options.externalLinksInNewTab) {
externalRel.push("noopener");
}
if (lineLength < MAX_GFM_LINES) {
stack = stack.use(remarkGfm, {
singleTilde: false,
});
}
const effectiveSchema = { ...ruleset.schema };
if (options.renderingContext === "ask" && !ruleset.ask.allowEmbeddedMedia) {
effectiveSchema.tagNames = _.filter(
effectiveSchema.tagNames,
(tagName) => !["img", "picture", "audio", "video"].includes(tagName)
);
}
return stack
.use(remarkRehype)
.use(() => copyImgAltToTitle)
.use(() => cleanUpFootnotes)
.use(rehypeSanitize, effectiveSchema)
.use(() => ruleset.additionalVisitor)
.use(rehypeExternalLinks, {
rel: externalRel,
target: options.externalLinksInNewTab ? "_blank" : "_self",
});
};
export function renderMarkdownNoHTML(
src: string,
publishDate: Date,
options: RenderingOptions
): string {
const lineLength = src.split("\n", MAX_GFM_LINES).length;
return markdownRenderStackNoHTML(publishDate, lineLength, options)
.use(rehypeStringify)
.processSync(src)
.toString();
}
export function renderSummaryNoHTML(
src: string,
publishDate: Date,
options: RenderingOptions
): string {
const renderedBody = renderMarkdownNoHTML(src, publishDate, options);
return convert(renderedBody);
}
export function renderMarkdownReactNoHTML(
src: string,
publishDate: Date,
options: RenderingOptions
) {
const components = {
Mention,
CustomEmoji,
};
if (options.renderingContext === "artistAlley") {
// remove headers for artist alley
Object.assign(components, {
h1: "strong",
h2: "strong",
h3: "strong",
h4: "strong",
h5: "strong",
h6: "strong",
});
}
const lineLength = src.split("\n", MAX_GFM_LINES).length;
return (
markdownRenderStackNoHTML(publishDate, lineLength, options)
.use(() => convertMentions)
.use(parseEmoji, { cohostPlus: options.hasCohostPlus })
// @ts-expect-error rehype-react types are broken
.use(rehypeReact, {
createElement,
Fragment,
components,
})
.processSync(src).result
);
}

384
src/lib/post-rendering.tsx Normal file
View File

@@ -0,0 +1,384 @@
import { InfoBox } from "../components/info-box";
import { CustomEmoji } from "../components/custom-emoji";
import {
isAskViewBlock,
isMarkdownViewBlock,
MarkdownViewBlock,
summaryContent,
ViewBlock,
} from "../types/post-blocks";
import { PostASTMap, WirePostViewModel } from "../types/wire-models";
import { compile } from "html-to-text";
import { DateTime } from "luxon";
import React, { createElement, Fragment, JSX } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import rehypeExternalLinks from "rehype-external-links";
import rehypeRaw from "rehype-raw";
import rehypeReact from "rehype-react";
import rehypeSanitize from "rehype-sanitize";
import rehypeStringify from "rehype-stringify";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { Plugin, Processor, unified } from "unified";
import { Mention } from "../components/mention";
import { parseEmoji } from "./emoji";
import { chooseAgeRuleset } from "./sanitize";
import { MAX_GFM_LINES, RenderingOptions } from "./shared-types";
import {
cleanUpFootnotes,
compileHastAST,
convertMentions,
copyImgAltToTitle,
parseHastAST,
} from "./unified-processors";
import _ from "lodash";
import type { Root as HASTRoot } from "hast";
import type { Root as MDASTRoot } from "mdast";
const convert = compile({
wordwrap: false,
});
/**
* Used for posts only, supports arbitrary HTML
* @returns
*/
const markdownRenderStack = (
postDate: Date,
lineLength: number,
options: Pick<RenderingOptions, "renderingContext">
) => {
let stack = unified().use(remarkParse);
const ruleset = chooseAgeRuleset(postDate);
if (ruleset.singleLineBreaks) {
stack = stack.use(remarkBreaks);
}
if (lineLength < MAX_GFM_LINES) {
stack = stack.use(remarkGfm, {
singleTilde: false,
});
}
// make a copy so we don't accidentally modify it in-place
const effectiveSchema = { ...ruleset.schema };
if (options.renderingContext === "ask" && !ruleset.ask.allowEmbeddedMedia) {
effectiveSchema.tagNames = _.filter(
effectiveSchema.tagNames,
(tagName) => !["img", "picture", "audio", "video"].includes(tagName)
);
}
return stack
.use(
remarkRehype as Plugin<
[{ allowDangerousHtml: boolean }],
MDASTRoot,
HASTRoot
>,
{
allowDangerousHtml: true,
}
)
.use(copyImgAltToTitle)
.use(() => cleanUpFootnotes)
.use(rehypeRaw)
.use(rehypeSanitize, effectiveSchema)
.use(() => ruleset.additionalVisitor);
};
const ERROR_BOX_NODE = (
<InfoBox level="post-box-warning">
<p>
There was an issue rendering the HTML for this post! This usually means
you've messed up syntax on a <code>style</code> attribute. Please check
your syntax!
</p>
</InfoBox>
);
const ERROR_BOX_HTML = renderToStaticMarkup(ERROR_BOX_NODE);
async function renderMarkdownAst(
blocks: MarkdownViewBlock[],
publishDate: Date,
options: Pick<RenderingOptions, "hasCohostPlus" | "renderingContext">
): Promise<string> {
const src = blocks.map((block) => block.markdown.content).join("\n\n");
let lineLength = 0;
// get the max line length among the blocks. while we group all blocks
// together for rendering, the performance regression associated with GFM
// tables only occurs with single line breaks, which can only exist within a
// single block. if the total number of line breaks ACROSS THE ENTIRE POST
// is >256, this isn't an issue. we're only impacted if it's in a single
// block.
for (const block of blocks) {
if (lineLength >= MAX_GFM_LINES) {
break;
}
lineLength = Math.max(
lineLength,
block.markdown.content.split("\n", MAX_GFM_LINES).length
);
}
return markdownRenderStack(publishDate, lineLength, options)
.use(() => convertMentions)
.use(parseEmoji, { cohostPlus: options.hasCohostPlus })
.use(compileHastAST)
.process(src)
.then((result) => result.value.toString())
.catch((e) => {
// re-run the renderer with our static error box. we only get errors
// when a user has an invalid style tag that fails parsing. our error
// box is Known Good so this is not a concern for us.
return renderMarkdownAst(
[
{
type: "markdown",
markdown: {
content: ERROR_BOX_HTML,
},
},
],
publishDate,
options
);
});
}
export function renderReactFromAst(
astString: string,
options: Omit<RenderingOptions, "hasCohostPlus">
) {
let stack = unified().use(parseHastAST);
const externalRel = ["nofollow"];
if (options.externalLinksInNewTab) {
externalRel.push("noopener");
}
try {
return (
stack
.use(rehypeExternalLinks, {
rel: externalRel,
target: options.externalLinksInNewTab ? "_blank" : "_self",
})
// @ts-expect-error rehype-react types are broken
.use(rehypeReact, {
createElement,
Fragment,
components: {
Mention,
CustomEmoji,
},
})
.processSync(astString).result
);
} catch (e) {
return ERROR_BOX_NODE;
}
}
function renderMarkdown(src: string, publishDate: Date): string {
const lineLength = src.split("\n", MAX_GFM_LINES).length;
return markdownRenderStack(publishDate, lineLength, {
renderingContext: "post",
})
.use(rehypeStringify)
.processSync(src)
.toString();
}
export function renderPostSummary(
viewModel: WirePostViewModel,
options: { myPost: boolean; rss?: boolean; skipHeadline?: boolean }
): string {
// invocations with either options.rss set true, or options.skipHeadline
// set true, have a second field they can use to carry the headline of the
// post and don't need to have it embedded herein.
const effectiveSkipHeadline = options.skipHeadline || options.rss;
if (!options.myPost) {
if (viewModel.effectiveAdultContent && viewModel.cws.length > 0) {
const cwList = viewModel.cws.join(", ");
return `18+ content; content warnings: ${cwList}`;
} else if (viewModel.cws.length > 0) {
const cwList = viewModel.cws.join(", ");
return `(content warning: ${cwList})`;
} else if (viewModel.effectiveAdultContent) {
return "this post contains 18+ content";
}
}
if (viewModel.transparentShareOfPostId) {
// transparent share; find the opaque post above it
const originalPost = viewModel.shareTree.find(
(vm) => vm.postId === viewModel.transparentShareOfPostId
);
if (options.rss) {
// RSS: just include a link in the summary
if (originalPost) {
return `Share from @${originalPost.postingProject.handle}: ${originalPost.singlePostPageUrl}`;
}
} else {
// on-site: nest opaque parent's preview inside this one
if (originalPost) {
return `Share from @${
originalPost.postingProject.handle
}: ${renderPostSummary(originalPost, options)}`;
}
}
}
let summary: string = "";
if (viewModel.headline && !effectiveSkipHeadline) {
summary = viewModel.headline;
} else {
const effectiveDate = viewModel.publishedAt
? DateTime.fromISO(viewModel.publishedAt).toJSDate()
: new Date();
const askBlocks = viewModel.blocks.filter(isAskViewBlock);
const markdownBlocks = viewModel.blocks.filter(isMarkdownViewBlock);
const textBlocks = [...askBlocks, ...markdownBlocks];
const textContent = (textBlocks.length > 0 ? textBlocks : viewModel.blocks)
.map((block) => summaryContent(block))
.join("\n\n");
const renderedBody = renderMarkdown(textContent, effectiveDate);
summary = convert(renderedBody);
}
if (options.rss && viewModel.shareOfPostId) {
// contentful share, include the link at the start of the summary
const originalPost = viewModel.shareTree.find(
(vm) => vm.postId === viewModel.shareOfPostId
);
if (originalPost) {
summary = `Share from @${originalPost.postingProject.handle}: ${originalPost.singlePostPageUrl}\n\n${summary}`;
}
}
return summary;
}
export async function generatePostAst(
viewBlocks: ViewBlock[],
publishDate: Date,
options: Pick<RenderingOptions, "hasCohostPlus" | "renderingContext">
): Promise<PostASTMap> {
// identify markdown spans
const spans: {
startIndex: number;
endIndex: number;
}[] = [];
let currentSpanStartIndex: number | null = null;
let readMoreIndex: number | null = null;
for (let i = 0; i < viewBlocks.length; i++) {
const block = viewBlocks[i];
const isMarkdownBlock = isMarkdownViewBlock(block);
if (isMarkdownBlock) {
if (
currentSpanStartIndex !== null &&
block.markdown.content === "---" &&
readMoreIndex === null
) {
// inside a span, content is "---", no read-more yet: end the
// span, set read-more index, and start a new one
spans.push({
startIndex: currentSpanStartIndex,
endIndex: i,
});
currentSpanStartIndex = i;
readMoreIndex = i;
} else if (currentSpanStartIndex !== null) {
// inside a span, any other content: keep it going
continue;
} else {
// outside a span: start a new span
currentSpanStartIndex = i;
}
} else {
if (currentSpanStartIndex !== null) {
// inside a span: end the span
spans.push({
startIndex: currentSpanStartIndex,
endIndex: i,
});
currentSpanStartIndex = null;
} else {
// outside a span: do nothing
continue;
}
}
}
// if we ended the post in a span, finish the one we were in
if (currentSpanStartIndex !== null) {
spans.push({
startIndex: currentSpanStartIndex,
endIndex: viewBlocks.length,
});
}
// render each span and return AST map
return {
spans: await Promise.all(
spans.map(async (span) => ({
startIndex: span.startIndex,
endIndex: span.endIndex,
ast: await renderMarkdownAst(
viewBlocks.slice(
span.startIndex,
span.endIndex
) as MarkdownViewBlock[],
publishDate,
options
),
}))
),
readMoreIndex,
};
}
// interim rendering method for until we get the rest of the inline attachments
// changes done. render a sequence of markdown spans all joined together.
export function renderReactFromSpans(
spans: PostASTMap["spans"],
options: Omit<RenderingOptions, "hasCohostPlus">
) {
const rendered: JSX.Element[] = [];
for (let i = 0; i < spans.length; i++) {
// throw a warning if there are any missing blocks in the middle of the
// rendered spans. this should only happen if we're attempting to
// render the markdown in a post with attachments in the middle, which
// shouldn't exist yet.
if (i != 0 && spans[i].startIndex !== spans[i - 1].endIndex) {
console.error("renderReactFromSpans: span interval is sparse?");
}
rendered.push(
<React.Fragment key={`span-${spans[i].startIndex}`}>
{renderReactFromAst(spans[i].ast, options)}
</React.Fragment>
);
}
return <>{rendered}</>;
}

340
src/lib/sanitize.tsx Normal file
View File

@@ -0,0 +1,340 @@
// this file is a .tsx so that tailwind will pick up on it
import deepmerge from "deepmerge";
import type { Root } from "hast";
import { Schema } from "hast-util-sanitize";
import { noop } from "lodash";
import { defaultSchema } from "rehype-sanitize";
import parseStyle from "style-to-object";
import { visit } from "unist-util-visit";
type AgeRuleset = {
schema: Schema;
cutoffDate: Date | null;
className: string;
additionalVisitor: (hast: Root) => void;
singleLineBreaks: boolean;
forceAttachmentsToTop: boolean;
attachmentLayoutBehavior: "v1" | "v2";
ask: {
allowEmbeddedMedia: boolean;
};
};
const FIRST_AGE: AgeRuleset = {
schema: deepmerge(defaultSchema, {
attributes: {
"*": ["style"],
},
tagNames: ["video", "audio", "aside"], // consistency with current rules,
}),
// Wednesday, June 29, 2022 6:00:00 PM GMT
cutoffDate: new Date(1656525600000),
className: "",
additionalVisitor: noop,
singleLineBreaks: false,
forceAttachmentsToTop: true,
attachmentLayoutBehavior: "v1",
ask: {
allowEmbeddedMedia: true,
},
};
const SECOND_AGE: AgeRuleset = {
schema: deepmerge(defaultSchema, {
attributes: {
"*": ["style"],
},
tagNames: ["video", "audio", "aside"], // consistency with current rules,
}),
// Monday, November 14, 2022 6:00:00 AM GMT
cutoffDate: new Date(1668405600000),
className: "isolate",
additionalVisitor(hast) {
visit(hast, "element", (node, index, parent) => {
if (parent === null || index === null) return;
if (node.properties?.style && typeof node.properties.style === "string") {
try {
let changed = false;
const parsed = parseStyle(node.properties.style);
if (
parsed &&
parsed["position"] &&
[
// every valid value of `position` _except_ `fixed`
// (https://developer.mozilla.org/en-US/docs/Web/CSS/position),
// which we disallow
"static",
"relative",
"absolute",
"sticky",
"inherit",
"initial",
"revert",
"revert-layer",
"unset",
].indexOf(parsed["position"].toLowerCase()) === -1
) {
parsed.position = "static";
changed = true;
}
if (parsed && changed) {
node.properties.style = Object.entries(parsed)
.map(([k, v]) => `${k}:${v}`)
.join(";");
}
} catch (e) {
// couldn't parse, don't worry about it
return;
}
}
});
},
singleLineBreaks: false,
forceAttachmentsToTop: true,
attachmentLayoutBehavior: "v1",
ask: {
allowEmbeddedMedia: true,
},
};
const THIRD_AGE: AgeRuleset = {
schema: deepmerge(defaultSchema, {
attributes: {
"*": ["style"],
},
tagNames: ["video", "audio", "aside"], // consistency with current rules,
}),
// may 10 2023, 3pm EDT
cutoffDate: new Date("2023-05-10T15:00:00-04:00"),
className: "isolate co-contain-paint",
additionalVisitor(hast) {
// run the previous age's visitor first
SECOND_AGE.additionalVisitor(hast);
visit(hast, "element", (node, index, parent) => {
if (parent === null || index === null) return;
if (node.properties?.style && typeof node.properties.style === "string") {
try {
let changed = false;
const parsed = parseStyle(node.properties.style);
if (parsed) {
for (const key in parsed) {
// drop all CSS variables
if (key.startsWith("--")) {
delete parsed[key];
changed = true;
}
}
}
if (parsed && changed) {
node.properties.style = Object.entries(parsed)
.map(([k, v]) => `${k}:${v}`)
.join(";");
}
} catch (e) {
// couldn't parse, don't worry about it
return;
}
}
});
},
singleLineBreaks: false,
forceAttachmentsToTop: true,
attachmentLayoutBehavior: "v1",
ask: {
allowEmbeddedMedia: true,
},
};
const FOURTH_AGE: AgeRuleset = {
// no schema changes from third age
schema: THIRD_AGE.schema,
// july 17 2023, noon EDT
cutoffDate: new Date("2023-07-17T12:00:00-04:00"),
className: THIRD_AGE.className,
additionalVisitor: THIRD_AGE.additionalVisitor,
singleLineBreaks: true,
forceAttachmentsToTop: true,
attachmentLayoutBehavior: "v1",
ask: {
allowEmbeddedMedia: true,
},
};
// list pulled from dompurify
const MATH_ML_SCHEMA: Schema = {
// allow mathml
tagNames: [
"math",
"menclose",
"merror",
"mfenced",
"mfrac",
"mglyph",
"mi",
"mlabeledtr",
"mmultiscripts",
"mn",
"mo",
"mover",
"mpadded",
"mphantom",
"mroot",
"mrow",
"ms",
"mspace",
"msqrt",
"mstyle",
"msub",
"msup",
"msubsup",
"mtable",
"mtd",
"mtext",
"mtr",
"munder",
"munderover",
"mprescripts",
],
attributes: {
"*": [
"accent",
"accentunder",
"align",
"bevelled",
"close",
"columnsalign",
"columnlines",
"columnspan",
"denomalign",
"depth",
"dir",
"display",
"displaystyle",
"encoding",
"fence",
"frame",
"height",
"href",
"id",
"largeop",
"length",
"linethickness",
"lspace",
"lquote",
"mathbackground",
"mathcolor",
"mathsize",
"mathvariant",
"maxsize",
"minsize",
"movablelimits",
"notation",
"numalign",
"open",
"rowalign",
"rowlines",
"rowspacing",
"rowspan",
"rspace",
"rquote",
"scriptlevel",
"scriptminsize",
"scriptsizemultiplier",
"selection",
"separator",
"separators",
"stretchy",
"subscriptshift",
"supscriptshift",
"symmetric",
"voffset",
"width",
"xmlns",
],
span: [["className", "math-inline"]],
div: [["className", "math-display"]],
},
};
const FIFTH_AGE: AgeRuleset = {
// we allow mathml now
schema: deepmerge(FOURTH_AGE.schema, MATH_ML_SCHEMA),
cutoffDate: new Date("2024-02-12T12:00:00-08:00"), // 2024/02/12 12:00 PST
className: FOURTH_AGE.className,
additionalVisitor: FOURTH_AGE.additionalVisitor,
singleLineBreaks: true,
forceAttachmentsToTop: true,
attachmentLayoutBehavior: "v1",
ask: {
allowEmbeddedMedia: true,
},
};
const SIXTH_AGE: AgeRuleset = {
schema: FIFTH_AGE.schema,
cutoffDate: new Date("2024-03-27T12:00:00-08:00"), // 2024/03/27 12:00 PDT
className: FIFTH_AGE.className,
additionalVisitor: FIFTH_AGE.additionalVisitor,
singleLineBreaks: true,
// attachments now render in post order instead of being pushed to the
// top of the post
forceAttachmentsToTop: false,
attachmentLayoutBehavior: "v1",
ask: {
allowEmbeddedMedia: true,
},
};
const SEVENTH_AGE: AgeRuleset = {
schema: SIXTH_AGE.schema,
cutoffDate: new Date("2024-03-29T12:00:00-08:00"), // 2024/03/29 12:00 PDT
className: SIXTH_AGE.className,
additionalVisitor: SIXTH_AGE.additionalVisitor,
singleLineBreaks: true,
forceAttachmentsToTop: false,
attachmentLayoutBehavior: "v2",
ask: {
allowEmbeddedMedia: true,
},
};
const EIGHTH_AGE: AgeRuleset = {
schema: SEVENTH_AGE.schema,
cutoffDate: null, // current age
className: SEVENTH_AGE.className,
additionalVisitor: SEVENTH_AGE.additionalVisitor,
singleLineBreaks: true,
forceAttachmentsToTop: false,
attachmentLayoutBehavior: "v2",
// asks no longer allow images, video, or audio to be embedded.
ask: {
allowEmbeddedMedia: false,
},
};
export const AGE_LIST = [
FIRST_AGE,
SECOND_AGE,
THIRD_AGE,
FOURTH_AGE,
FIFTH_AGE,
SIXTH_AGE,
SEVENTH_AGE,
EIGHTH_AGE,
] as const;
export const chooseAgeRuleset = (postDate: Date) =>
AGE_LIST.find((ruleset) => {
if (ruleset.cutoffDate) {
return postDate < ruleset.cutoffDate;
}
return true;
}) ?? AGE_LIST[AGE_LIST.length - 1];

27
src/lib/shared-types.ts Normal file
View File

@@ -0,0 +1,27 @@
export type RenderingContext =
| "activitypub"
| "ask"
| "comment"
| "email"
| "post"
| "profile"
| "rss"
| "artistAlley"
| null;
export type RenderingOptions = {
/** the context in which this content is being rendered */
renderingContext: RenderingContext;
/**
* whether or not the posting user has cohost plus (currently determines
* emoji rendering behavior)
*/
hasCohostPlus: boolean;
externalLinksInNewTab: boolean;
};
/**
* line length limit for enabling GFM tables. workaround for a performance issue
* with long profile descriptions.
*/
export const MAX_GFM_LINES = 256;

View File

@@ -0,0 +1,178 @@
import type { Element, Root, Text } from "hast";
import _ from "lodash";
import { Plugin } from "unified";
import type { Compiler, Parser } from "unified";
import { is } from "unist-util-is";
import { CONTINUE, SKIP, visit } from "unist-util-visit";
import { EXIT, visitParents } from "unist-util-visit-parents";
import { extractMentions } from "./mention-parsing";
export const processMatches =
(
regex: RegExp,
callback: (
matches: string[],
splits: string[],
node: Text,
index: number,
parent: Element | Root
) => void
) =>
(hast: Root) => {
// we only want to check on text nodes for this
visit(hast, "text", (node, index, parent) => {
// there is no such thing as a text node without a parent.
// but if there is we want nothing to do with it.
if (parent == null || index == null) return;
const matches = node.value.match(regex);
// if this text has mentions, process them
if (matches) {
const splits = node.value.split(regex);
if (splits.length - 1 !== matches.length) {
// something isn't how it should be. bail.
return;
}
return callback(matches, splits, node, index, parent);
}
});
};
export const convertMentions = (hast: Root) => {
// we only want to check on text nodes for this
visit(hast, "text", (node, index, parent) => {
// there is no such thing as a text node without a parent.
// but if there is we want nothing to do with it.
if (parent == null || index == null) return;
const text = node.value;
const names = extractMentions(text);
// if this text has mentions, consider processing them
if (names.length) {
// if we have an `a` in our parent tree, we don't want to process
// the mention so that links still work.
let hasAnchorParent = false;
visitParents(parent, { type: "text" }, (newNode, ancestors) => {
// since we have to traverse for all text nodes on the parent,
// we will pretty often get text nodes that _aren't_ the one
// we're trying to operate on. check to make sure they match.
if (!_.isEqual(node, newNode)) return CONTINUE;
// flag if we've got an anchro parent
hasAnchorParent = !!ancestors.find((el) =>
is(el, { type: "element", tagName: "a" })
);
// if we do, we don't need to check everything else so bail out.
if (hasAnchorParent) return EXIT;
});
if (hasAnchorParent) return CONTINUE;
const els: Array<Element | Text> = [];
let currentStart = 0;
names.forEach((token, idx, names) => {
const [startPosition, endPosition] = token.indices;
els.push({
type: "text",
value: text.slice(currentStart, startPosition),
});
els.push({
type: "element",
tagName: "Mention",
properties: {
handle: token.handle,
},
children: [
{
type: "text",
value: `@${token.handle}`,
},
],
});
currentStart = endPosition;
if (idx === names.length - 1) {
// if we're last we need to grab the rest of the string
els.push({
type: "text",
value: text.slice(currentStart),
});
}
});
parent.children.splice(index, 1, ...els);
// skip over all the new elements we just created
return [SKIP, index + els.length];
}
});
};
export const cleanUpFootnotes = (hast: Root) => {
visit(hast, "element", (node, index, parent) => {
if (parent == null || index == null) return;
// remove the link from the superscript number
if (
node.tagName === "a" &&
(node.properties?.id as string)?.includes("fnref")
) {
parent.children.splice(index, 1, ...node.children);
return [SKIP, index];
}
// remove the little arrow at the bottom
if (
node.tagName === "a" &&
(node.properties?.href as string)?.includes("fnref")
) {
parent.children.splice(index, 1);
return [SKIP, index];
}
// replace the invisible label with a hr
if (
node.tagName === "h2" &&
(node.properties?.id as string)?.includes("footnote-label")
) {
const hrEl: Element = {
tagName: "hr",
type: "element",
children: [],
properties: {
"aria-label": "Footnotes",
style: "margin-bottom: -0.5rem;",
},
};
parent.children.splice(index, 1, hrEl);
}
});
};
export const copyImgAltToTitle: Plugin<[], Root> = () => (hast: Root) => {
visit(hast, { type: "element", tagName: "img" }, (node) => {
if (node.properties?.alt) {
node.properties.title = node.properties.alt;
}
});
};
export const compileHastAST: Plugin = function () {
const compiler: Compiler<Root, string> = (node: Root) => {
return JSON.stringify(node);
};
Object.assign(this, { Compiler: compiler });
};
export const parseHastAST: Plugin = function () {
const parser: Parser<Root> = (astString: string) => {
const ast = JSON.parse(astString) as Root;
return ast;
};
Object.assign(this, { Parser: parser });
};

View File

@@ -0,0 +1,10 @@
import { z } from "zod";
export enum AccessResult {
Allowed = "allowed",
NotAllowed = "not-allowed",
LogInFirst = "log-in-first",
Blocked = "blocked",
}
export const AccessResultEnum = z.enum(AccessResult);

37
src/types/asks.ts Normal file
View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import { AskId, ISODateString, ProjectHandle, ProjectId } from "./ids";
import { AvatarShape, ProjectFlag, ProjectPrivacyEnum } from "./projects";
export const AskState = z.enum(["pending", "responded", "deleted"]);
export type AskState = z.infer<typeof AskState>;
const FilteredProject = z.object({
projectId: ProjectId,
handle: ProjectHandle,
avatarURL: z.string().url(),
avatarPreviewURL: z.string().url(),
privacy: ProjectPrivacyEnum,
flags: ProjectFlag.array(),
avatarShape: AvatarShape,
displayName: z.string(),
});
export const WireAskModel = z.discriminatedUnion("anon", [
z.object({
anon: z.literal(true),
loggedIn: z.boolean(),
askingProject: z.undefined(),
askId: AskId,
content: z.string(),
sentAt: ISODateString,
}),
z.object({
anon: z.literal(false),
loggedIn: z.literal(true),
askingProject: FilteredProject,
askId: AskId,
content: z.string(),
sentAt: ISODateString,
}),
]);
export type WireAskModel = z.infer<typeof WireAskModel>;

30
src/types/attachments.ts Normal file
View File

@@ -0,0 +1,30 @@
import z from "zod";
export enum AttachmentState {
Pending = 0,
Finished,
}
export const AttachmentKind = z.enum(["audio", "image"]);
export type AttachmentKind = z.infer<typeof AttachmentKind>;
export const WireAudioAttachmentMetadata = z.object({
title: z.string().optional(),
artist: z.string().optional(),
});
export type WireAudioAttachmentMetadata = z.infer<
typeof WireAudioAttachmentMetadata
>;
export const WireImageAttachmentMetadata = z.object({});
export type WireImageAttachmentMetadata = z.infer<
typeof WireImageAttachmentMetadata
>;
export const WireAnyAttachmentMetadata = z.union([
WireAudioAttachmentMetadata,
WireImageAttachmentMetadata,
]);
export type WireAnyAttachmentMetadata = z.infer<
typeof WireAnyAttachmentMetadata
>;

44
src/types/ids.ts Normal file
View File

@@ -0,0 +1,44 @@
import { EXTANT_PAGE_LEGAL_REGEX } from "./username-verifier";
import { DateTime } from "luxon";
import z from "zod";
import { Tagged, refinement } from "./tagged";
const BigIntId = z
.string()
.or(z.number().transform((val) => val.toString()))
.refine((val) => {
try {
// BigInt throws on non-integer strings
const num = BigInt(val);
return num > BigInt(0);
} catch (e) {
return false;
}
});
export const AttachmentId = z.uuid().brand<"AttachmentId">();
export type AttachmentId = z.infer<typeof AttachmentId>;
export const PostId = z.int().brand<"PostId">();
export type PostId = z.infer<typeof PostId>;
export const ProjectId = z.int().brand<"ProjectId">();
export type ProjectId = z.infer<typeof ProjectId>;
export const UserId = z.int().brand<"UserId">();
export type UserId = z.infer<typeof UserId>;
export const ProjectHandle = z
.string()
// use the old legal regex to prevent tRPC errors with DNS-illegal usernames
.regex(EXTANT_PAGE_LEGAL_REGEX)
.brand<"ProjectHandle">();
export type ProjectHandle = z.infer<typeof ProjectHandle>;
export const CommentId = z.uuid().brand<"CommentId">();
export type CommentId = z.infer<typeof CommentId>;
export const ISODateString = z.iso.datetime({ offset: true });
export const AskId = BigIntId.brand<"AskId">();
export type AskId = z.infer<typeof AskId>;

5
src/types/limits.ts Normal file
View File

@@ -0,0 +1,5 @@
// length limit for usernames and project handles
export const USERNAME_HANDLE_LIMIT = 200;
// length limit for asks
export const ASK_LENGTH_LIMIT = 1120;

244
src/types/post-blocks.ts Normal file
View File

@@ -0,0 +1,244 @@
/**
* Post Blocks
*
* Specifies all the types for the block-based Post system.
* @module
*/
import { z } from "zod";
import { WireAskModel } from "./asks";
import { WireImageAttachmentMetadata } from "./attachments";
import { AttachmentId } from "./ids";
/**
* @internal
*/
const BaseBlock = z.object({
type: z.string(),
});
interface BaseBlock {
type: string;
}
/**
* @category Storage Blocks
*
* Blocks as they are stored in the database.
* Only contains minimal information needed to reproduce content, used as the base to generate [[View Blocks]].
*/
/**
* @category Storage Blocks
*/
export const MarkdownStorageBlock = BaseBlock.extend({
type: z.literal("markdown"),
markdown: z.object({
/** Raw markdown to be parsed at render-time. */
content: z.string(),
}),
});
export type MarkdownStorageBlock = z.infer<typeof MarkdownStorageBlock>;
/**
* @category Storage Blocks
*/
export const AttachmentStorageBlock = BaseBlock.extend({
type: z.literal("attachment"),
attachment: z.object({
/** ID for the [[`Attachment`]] to be rendered. */
attachmentId: AttachmentId,
altText: z.string().optional(),
}),
});
export type AttachmentStorageBlock = z.infer<typeof AttachmentStorageBlock>;
export const AttachmentRowStorageBlock = BaseBlock.extend({
type: z.literal("attachment-row"),
attachments: z.array(AttachmentStorageBlock),
});
export type AttachmentRowStorageBlock = z.infer<
typeof AttachmentRowStorageBlock
>;
/**
* Union type used on the [[`Post`]] model
*
* @category Storage Blocks
*/
export const StorageBlock = z.union([
MarkdownStorageBlock,
AttachmentStorageBlock,
AttachmentRowStorageBlock,
]);
export type StorageBlock = z.infer<typeof StorageBlock>;
/**
* @category View Blocks
* View Blocks _must_ contain all data needed to render the block.
* This is a wire-safe type and as a result _must_ not contain anything the client can't see.
*
*/
/**
* No changes are currently required from the [[`MarkdownStorageBlock`]]
* so this is currently a simple alias.
*
* @category View Blocks
* */
export const MarkdownViewBlock = MarkdownStorageBlock.extend({});
export type MarkdownViewBlock = z.infer<typeof MarkdownViewBlock>;
/**
* Adds the image URL for rendering
*
* @category View Blocks
*/
const ImageAttachmentViewModel = AttachmentStorageBlock.shape.attachment
.extend({
previewURL: z.string(),
fileURL: z.string(),
kind: z.literal("image"),
width: z.number().nullish(),
height: z.number().nullish(),
})
.extend(WireImageAttachmentMetadata.shape);
const AudioAttachmentViewModel = AttachmentStorageBlock.shape.attachment.extend(
{
previewURL: z.string(),
fileURL: z.string(),
kind: z.literal("audio"),
artist: z.string().optional(),
title: z.string().optional(),
}
);
const AttachmentViewModel = z.discriminatedUnion("kind", [
ImageAttachmentViewModel,
AudioAttachmentViewModel,
]);
export const AttachmentViewBlock = AttachmentStorageBlock.extend({
attachment: AttachmentViewModel,
});
export type AttachmentViewBlock = z.infer<typeof AttachmentViewBlock>;
export const AttachmentRowViewBlock = AttachmentRowStorageBlock.extend({
attachments: z.array(AttachmentViewBlock),
});
export type AttachmentRowViewBlock = z.infer<typeof AttachmentRowViewBlock>;
/**
* NOTE: AskViewBlock DOES NOT have a corresponding storage block. It is
* generated by the server.
*/
export const AskViewBlock = BaseBlock.extend({
type: z.literal("ask"),
ask: WireAskModel,
});
export type AskViewBlock = z.infer<typeof AskViewBlock>;
/**
* Union type used for [[`PostViewModel`]] and component renderers.
* @category View Blocks
*/
export const ViewBlock = z.union([
MarkdownViewBlock,
AttachmentViewBlock,
AskViewBlock,
AttachmentRowViewBlock,
]);
export type ViewBlock = z.infer<typeof ViewBlock>;
export function isAttachmentViewBlock(
test: unknown
): test is AttachmentViewBlock {
return AttachmentViewBlock.safeParse(test).success;
}
export function isMarkdownViewBlock(test: unknown): test is MarkdownViewBlock {
return MarkdownViewBlock.safeParse(test).success;
}
export function isAskViewBlock(test: unknown): test is AskViewBlock {
return AskViewBlock.safeParse(test).success;
}
export function isAttachmentRowViewBlock(
test: unknown
): test is AttachmentRowViewBlock {
return AttachmentRowViewBlock.safeParse(test).success;
}
export function isAttachmentStorageBlock(
test: unknown
): test is AttachmentStorageBlock {
return AttachmentStorageBlock.safeParse(test).success;
}
export function isAttachmentRowStorageBlock(
test: unknown
): test is AttachmentRowStorageBlock {
return AttachmentRowStorageBlock.safeParse(test).success;
}
export function isMarkdownStorageBlock(
test: unknown
): test is MarkdownStorageBlock {
return MarkdownStorageBlock.safeParse(test).success;
}
// via https://github.com/colinhacks/zod/issues/627#issuecomment-911679836
// could be worth investigating a factory function?
export function parseAttachmentViewBlocks(originalBlocks: unknown[]) {
return z
.preprocess(
(blocks) => z.array(z.any()).parse(blocks).filter(isAttachmentViewBlock),
z.array(AttachmentViewBlock)
)
.parse(originalBlocks);
}
export function summaryContent(block: ViewBlock): string {
switch (block.type) {
case "markdown":
return block.markdown.content;
case "attachment": {
const encodedFilename = block.attachment.fileURL.split("/").pop();
return encodedFilename
? `[${block.attachment.kind}: ${decodeURIComponent(encodedFilename)}]`
: `[${block.attachment.kind}]`;
}
case "attachment-row":
return block.attachments
.map((attachment) => summaryContent(attachment))
.join("\n");
case "ask": {
const askerString = block.ask.anon
? block.ask.loggedIn
? "Anonymous User asked:"
: "Anonymous Guest asked:"
: `@${block.ask.askingProject.handle} asked:`;
return `${askerString}
> ${block.ask.content.split("\n").join("\n> ")}`;
}
}
}
export function getAttachmentViewBlocks(
blocks: ViewBlock[]
): AttachmentViewBlock[] {
return blocks.reduce<AttachmentViewBlock[]>((blocks, block) => {
if (isAttachmentViewBlock(block)) {
return [...blocks, block];
}
if (isAttachmentRowViewBlock(block)) {
return [...blocks, ...block.attachments];
}
return blocks;
}, []);
}

10
src/types/posts.ts Normal file
View File

@@ -0,0 +1,10 @@
import { z } from "zod";
export enum PostState {
Unpublished = 0,
Published,
Deleted,
}
export const PostStateEnum = z.enum(PostState);
export type PostStateEnum = z.infer<typeof PostStateEnum>;

69
src/types/projects.ts Normal file
View File

@@ -0,0 +1,69 @@
import { z } from "zod";
import { ISODateString, ProjectHandle, ProjectId } from "./ids";
export enum ProjectPrivacy {
Public = "public",
Private = "private",
}
export const ProjectPrivacyEnum = z.enum(ProjectPrivacy);
export type ProjectPrivacyEnum = z.infer<typeof ProjectPrivacyEnum>;
// shout out https://stackoverflow.com/a/61129291
export const ProjectFlag = z.enum([
"staff",
"staffMember",
"friendOfTheSite",
"noTransparentAvatar",
"suspended",
"automated", // used for the bot badge
"parody", // used for the "un-verified" badge
]);
export type ProjectFlag = z.infer<typeof ProjectFlag>;
// TODO: the DB also supports "all-ages", but this setting doesn't currently
// work because of an NYI access checker upgrade.
export const LoggedOutPostVisibility = z.enum(["public", "none"]);
export type LoggedOutPostVisibility = z.infer<typeof LoggedOutPostVisibility>;
export function isProjectFlag(
maybeProjectFlag: unknown
): maybeProjectFlag is ProjectFlag {
return ProjectFlag.safeParse(maybeProjectFlag).success;
}
export const AvatarShape = z.enum([
"circle",
"roundrect",
"squircle",
"capsule-big",
"capsule-small",
"egg",
]);
export type AvatarShape = z.infer<typeof AvatarShape>;
export const WireProjectModel = z.object({
projectId: ProjectId,
handle: ProjectHandle,
displayName: z.string(),
dek: z.string(),
description: z.string(),
avatarURL: z.string().url(),
avatarPreviewURL: z.string().url(),
headerURL: z.string().url().nullable(),
headerPreviewURL: z.string().url().nullable(),
privacy: ProjectPrivacyEnum,
url: z.string().nullable(),
pronouns: z.string().nullable(),
flags: ProjectFlag.array(),
avatarShape: AvatarShape,
loggedOutPostVisibility: LoggedOutPostVisibility,
frequentlyUsedTags: z.string().array(),
askSettings: z.object({
enabled: z.boolean(),
allowAnon: z.boolean(),
requireLoggedInAnon: z.boolean(),
}),
deleteAfter: ISODateString.nullable(),
isSelfProject: z.boolean().nullable(),
});
export type WireProjectModel = z.infer<typeof WireProjectModel>;

7
src/types/tagged.ts Normal file
View File

@@ -0,0 +1,7 @@
// based on https://github.com/colinhacks/zod/issues/678#issuecomment-962387521
export type Tagged<T, Tag> = T & { __tag: Tag };
export function refinement<Type extends Tagged<T, any>, T>() {
return function (val: T): val is Type {
return true;
};
}

View File

@@ -0,0 +1,62 @@
import { USERNAME_HANDLE_LIMIT } from "./limits";
export type UsernameLegalResult =
| {
legal: true;
}
| {
legal: false;
reason: string;
};
export const LEGAL_REGEX_STRING = "^[a-zA-Z0-9][a-zA-Z0-9-]{2,}$";
export const LEGAL_REGEX = new RegExp(LEGAL_REGEX_STRING);
// separate regular expression for existing pages; fixes tRPC bugs with pages that are invalid DNS names but already exist
export const EXTANT_PAGE_LEGAL_REGEX = /^[a-zA-Z0-9-]{3,}/;
const BANNED_USERNAMES = new Set([
"rc",
"api",
"www",
"help",
"admin",
"support",
"staff",
"internal",
"status",
"mail",
"mobile",
"search",
"static",
]);
export function isHandleLegal(username: string): UsernameLegalResult {
const downcasedUsername = username.toLowerCase();
// no namespace usernames
if (BANNED_USERNAMES.has(downcasedUsername)) {
return {
legal: false,
reason: `Username can not be "${username}"`,
};
}
// legal characters only
if (!LEGAL_REGEX.test(username)) {
return {
legal: false,
reason:
"Usernames can only contain letters, numbers, and hyphens, and must be at least 3 characters.",
};
}
if (downcasedUsername.length > USERNAME_HANDLE_LIMIT) {
return {
legal: false,
reason:
"Your username cannot be longer than 200 characters, but nice try.",
};
}
return { legal: true };
}

183
src/types/wire-models.ts Normal file
View File

@@ -0,0 +1,183 @@
import { string, z } from "zod";
import { AccessResult, AccessResultEnum } from "./access-result";
import {
AskId,
AttachmentId,
CommentId,
ISODateString,
PostId,
ProjectId,
UserId,
} from "./ids";
import { StorageBlock, ViewBlock } from "./post-blocks";
import { PostStateEnum } from "./posts";
import { ProjectFlag, WireProjectModel } from "./projects";
// Type data needed for posts on the client
export const WirePostContentCommon = z.object({
postId: PostId,
headline: z.string(),
publishedAt: z.string().optional(),
filename: z.string(),
transparentShareOfPostId: PostId.nullable(),
shareOfPostId: PostId.nullable(),
state: PostStateEnum,
numComments: z.number(),
cws: z.string().array(),
tags: z.string().array(),
hasCohostPlus: z.boolean(),
pinned: z.boolean(),
commentsLocked: z.boolean(),
sharesLocked: z.boolean(),
});
export type WirePostContentCommon = z.infer<typeof WirePostContentCommon>;
export const WirePostModel = WirePostContentCommon.extend({
adultContent: z.boolean(),
shareOfPostId: PostId.nullable(),
updatedAt: z.string(),
blocks: StorageBlock.array(),
attachments: z
.object({ attachmentId: AttachmentId, filename: z.string() })
.array(),
});
export type WirePostModel = z.infer<typeof WirePostModel>;
// double declaration required due to a typescript limitation with recursive types
// see: https://github.com/colinhacks/zod#recursive-types
type WireCommentViewModelInternal = {
comment: {
commentId: CommentId;
postedAtISO: string;
deleted: boolean;
body: string;
children: WireCommentViewModelInternal[];
postId: PostId;
inReplyTo: CommentId | null;
hasCohostPlus: boolean;
hidden: boolean;
};
canInteract: AccessResult;
canEdit: AccessResult;
canHide: AccessResult;
poster?: WireProjectModel;
};
export const WireCommentViewModel: z.ZodType<WireCommentViewModelInternal> =
z.lazy(() =>
z.object({
comment: z.object({
commentId: CommentId,
postedAtISO: ISODateString,
deleted: z.boolean(),
body: z.string(),
children: WireCommentViewModel.array(),
postId: PostId,
inReplyTo: CommentId.nullable(),
hasCohostPlus: z.boolean(),
hidden: z.boolean(),
}),
canInteract: AccessResultEnum,
canEdit: AccessResultEnum,
canHide: AccessResultEnum,
poster: WireProjectModel.optional(),
})
);
export type WireCommentViewModel = z.infer<typeof WireCommentViewModel>;
export const WireRenderedPostContent = z.object({
initial: z.string(),
expanded: z.string().optional(),
});
export type WireRenderedPostContent = z.infer<typeof WireRenderedPostContent>;
export const LimitedVisibilityReason = z.enum([
"none",
"log-in-first",
"deleted",
"unpublished",
"adult-content",
"blocked",
]);
export type LimitedVisibilityReason = z.infer<typeof LimitedVisibilityReason>;
// rationale for building the post AST this way: originally, the entire post
// shared one AST; this doesn't suffice when attachments can move around. the
// natural way to break the AST down then is per-block, but an inline HTML tag
// can span multiple blocks, so we need to build spans of contiguous markdown
// blocks as a unit or else HTML's tag auto-insertion rules kick in.
export const PostASTMap = z.object({
spans: z.array(
z.object({
startIndex: z.number(),
endIndex: z.number(),
ast: z.string(),
})
),
readMoreIndex: z.number().nullable(),
});
export type PostASTMap = z.infer<typeof PostASTMap>;
// double declaration required due to a typescript limitation with recursive types
// see: https://github.com/colinhacks/zod#recursive-types
type WirePostViewModelInternal = WirePostContentCommon & {
blocks: ViewBlock[];
plainTextBody: string;
postingProject: WireProjectModel;
shareTree: WirePostViewModelInternal[];
numSharedComments: number;
relatedProjects: WireProjectModel[];
singlePostPageUrl: string;
effectiveAdultContent: boolean;
isEditor: boolean;
hasAnyContributorMuted: boolean;
contributorBlockIncomingOrOutgoing: boolean;
postEditUrl: string;
isLiked: boolean;
canShare: boolean;
canPublish: boolean;
limitedVisibilityReason: LimitedVisibilityReason;
astMap: PostASTMap;
responseToAskId: AskId | null;
};
export const WirePostViewModel: z.ZodType<WirePostViewModelInternal> = z.lazy(
() =>
WirePostContentCommon.extend({
blocks: ViewBlock.array(),
plainTextBody: z.string(),
postingProject: WireProjectModel,
shareTree: WirePostViewModel.array(),
numSharedComments: z.number(),
relatedProjects: WireProjectModel.array(),
singlePostPageUrl: z.string().url(),
effectiveAdultContent: z.boolean(),
isEditor: z.boolean(),
hasAnyContributorMuted: z.boolean(),
contributorBlockIncomingOrOutgoing: z.boolean(),
postEditUrl: z.string().url(),
isLiked: z.boolean(),
canShare: z.boolean(),
canPublish: z.boolean(),
limitedVisibilityReason: LimitedVisibilityReason,
astMap: PostASTMap,
responseToAskId: AskId.nullable(),
})
);
export type WirePostViewModel = z.infer<typeof WirePostViewModel>;
export const WireUserModel = z.object({
userId: UserId,
email: z.string(),
emailVerified: z.boolean(),
collapseAdultContent: z.boolean(),
isAdult: z.boolean(),
twoFactorEnabled: z.boolean(),
});
export type WireUserModel = z.infer<typeof WireUserModel>;