initial commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
77
package.json
Normal 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
2112
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
readme.md
Normal file
36
readme.md
Normal 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
11
rollup.config.ts
Normal 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()],
|
||||
};
|
||||
19
src/components/custom-emoji.tsx
Normal file
19
src/components/custom-emoji.tsx
Normal 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";
|
||||
41
src/components/info-box.tsx
Normal file
41
src/components/info-box.tsx
Normal 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>;
|
||||
};
|
||||
24
src/components/mention.tsx
Normal file
24
src/components/mention.tsx
Normal 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
2
src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./lib/post-rendering";
|
||||
export * from "./lib/other-rendering";
|
||||
107
src/lib/emoji.ts
Normal file
107
src/lib/emoji.ts
Normal 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;
|
||||
};
|
||||
89
src/lib/mention-parsing.ts
Normal file
89
src/lib/mention-parsing.ts
Normal 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
139
src/lib/other-rendering.ts
Normal 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
384
src/lib/post-rendering.tsx
Normal 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
340
src/lib/sanitize.tsx
Normal 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
27
src/lib/shared-types.ts
Normal 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;
|
||||
178
src/lib/unified-processors.ts
Normal file
178
src/lib/unified-processors.ts
Normal 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 });
|
||||
};
|
||||
10
src/types/access-result.ts
Normal file
10
src/types/access-result.ts
Normal 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
37
src/types/asks.ts
Normal 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
30
src/types/attachments.ts
Normal 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
44
src/types/ids.ts
Normal 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
5
src/types/limits.ts
Normal 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
244
src/types/post-blocks.ts
Normal 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
10
src/types/posts.ts
Normal 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
69
src/types/projects.ts
Normal 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
7
src/types/tagged.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
62
src/types/username-verifier.ts
Normal file
62
src/types/username-verifier.ts
Normal 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
183
src/types/wire-models.ts
Normal 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
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"target": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user