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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.DS_Store

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 anti software software club LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

77
package.json Normal file
View File

@@ -0,0 +1,77 @@
{
"name": "cohost-renderer",
"version": "1.0.0",
"description": "",
"module": "dist/index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "rollup -c --configPlugin @rollup/plugin-typescript"
},
"keywords": [],
"author": "anti software software club llc (https://antisoftware.club)",
"contributors": [
"jae kaplan <me@jkap.io> (https://jkap.io)",
"colin bayer (https://gameboat.org)"
],
"license": "MIT",
"files": [
"dist"
],
"devDependencies": {
"@rollup/plugin-typescript": "^12.1.4",
"@types/hast": "^2.3.4",
"@types/html-to-text": "^9.0.4",
"@types/lodash": "^4.17.20",
"@types/luxon": "^3.7.1",
"@types/mdast": "^3.0.10",
"@types/react": "^18",
"@types/react-dom": "^18",
"hast-util-sanitize": "^5.0.2",
"html-to-text": "^9.0.5",
"lodash": "^4.17.21",
"rollup": "^4.52.4",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"react": "^18",
"react-dom": "^18"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
},
"dependencies": {
"deepmerge": "^4.3.1",
"luxon": "^3.7.2",
"remark": "^15.0.1",
"style-to-object": "^1.0.11",
"zod": "^4.1.12",
"hast-util-sanitize": "^4.0.0",
"html-to-text": "^9.0.5",
"lodash": "^4.17.21",
"rehype-external-links": "^2.0.0",
"rehype-raw": "^6.1.1",
"rehype-react": "^7.1.1",
"rehype-remark": "^9.1.2",
"rehype-sanitize": "^5.0.1",
"rehype-stringify": "^9.0.3",
"remark-breaks": "^3.0.3",
"remark-gfm": "^3.0.1",
"remark-parse": "^10.0.1",
"remark-rehype": "^10.1.0",
"remark-stringify": "^10.0.2",
"unified": "^10.1.2",
"unist-builder": "^3.0.0",
"unist-util-is": "^5.2.0",
"unist-util-visit": "^4.1.0",
"unist-util-visit-parents": "^5.1.3"
}
}

2112
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

36
readme.md Normal file
View File

@@ -0,0 +1,36 @@
# cohost renderer
> No effort has been made to validate that this actually works outside of typechecking and bundling!
bare-minimum version that builds. no package yet, no instructions yet, this is
just the bare minimum extraction to make this build outside the cohost codebase.
i strongly recommend against using this for anything serious.
things that are missing that will not be added:
- `InfoBox` is a bare-minimum implementation that doesn't use any of our styling
because i didn't want to make tailwind a dependency here
- custom emoji loading is not implemented as it was extremely dependent on the
cohost build system. if you can populate the arrays then it should render, but
doing that is left as an exercise to the reader.
- probably other things i'm not thinking about
things that are missing that will be added:
- instructions
- an example
- an npm package
things that are here that will probably be removed at some point:
- extraneous IDs, types, etc. this was pulled straight out of the cohost
codebase and while i removed the obviously unnecessary bits (things related to
invites, for example) there might be some bonus bullshit in here.
- code related to AST caching. it's entirely unnecessary for non-production use.
# license
this release is licensed under the MIT license. the remaining cohost frontend
codebase is source-available (if you know where to look) but not open-source,
the backend remains private. we have no intention to open-source the remainder
of cohost at this time.

11
rollup.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import typescript from "@rollup/plugin-typescript";
export default {
input: "src/index.ts",
output: {
dir: "dist",
format: "es",
sourcemap: true,
},
plugins: [typescript()],
};

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>;

12
tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"target": "esnext",
"moduleResolution": "node",
"noEmit": true,
"skipLibCheck": true
},
"include": ["./src/**/*"]
}