AST removal

This commit is contained in:
jae kaplan
2025-10-17 20:19:07 -04:00
parent 732e0c4db6
commit fecb2e27d2
6 changed files with 53 additions and 162 deletions

View File

@@ -26,26 +26,20 @@
"@types/luxon": "^3.7.1", "@types/luxon": "^3.7.1",
"@types/mdast": "^3.0.10", "@types/mdast": "^3.0.10",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18",
"hast-util-sanitize": "^5.0.2", "hast-util-sanitize": "^5.0.2",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"rollup": "^4.52.4", "rollup": "^4.52.4",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"react": "^18", "react": "^18"
"react-dom": "^18"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=16", "react": ">=16"
"react-dom": ">=16"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"react": { "react": {
"optional": true "optional": true
},
"react-dom": {
"optional": true
} }
}, },
"dependencies": { "dependencies": {

33
pnpm-lock.yaml generated
View File

@@ -102,15 +102,9 @@ importers:
'@types/react': '@types/react':
specifier: ^18 specifier: ^18
version: 18.3.26 version: 18.3.26
'@types/react-dom':
specifier: ^18
version: 18.3.7(@types/react@18.3.26)
react: react:
specifier: ^18 specifier: ^18
version: 18.3.1 version: 18.3.1
react-dom:
specifier: ^18
version: 18.3.1(react@18.3.1)
rollup: rollup:
specifier: ^4.52.4 specifier: ^4.52.4
version: 4.52.4 version: 4.52.4
@@ -298,11 +292,6 @@ packages:
'@types/prop-types@15.7.15': '@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
peerDependencies:
'@types/react': ^18.0.0
'@types/react@18.3.26': '@types/react@18.3.26':
resolution: {integrity: sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==} resolution: {integrity: sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==}
@@ -733,11 +722,6 @@ packages:
property-information@6.5.0: property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
react: ^18.3.1
react@18.3.1: react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -803,9 +787,6 @@ packages:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
selderee@0.11.0: selderee@0.11.0:
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
@@ -1048,10 +1029,6 @@ snapshots:
'@types/prop-types@15.7.15': {} '@types/prop-types@15.7.15': {}
'@types/react-dom@18.3.7(@types/react@18.3.26)':
dependencies:
'@types/react': 18.3.26
'@types/react@18.3.26': '@types/react@18.3.26':
dependencies: dependencies:
'@types/prop-types': 15.7.15 '@types/prop-types': 15.7.15
@@ -1792,12 +1769,6 @@ snapshots:
property-information@6.5.0: {} property-information@6.5.0: {}
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
react: 18.3.1
scheduler: 0.23.2
react@18.3.1: react@18.3.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@@ -1953,10 +1924,6 @@ snapshots:
dependencies: dependencies:
mri: 1.2.0 mri: 1.2.0
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0
selderee@0.11.0: selderee@0.11.0:
dependencies: dependencies:
parseley: 0.12.1 parseley: 0.12.1

View File

@@ -6,7 +6,7 @@ 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. just the bare minimum extraction to make this build outside the cohost codebase.
i strongly recommend against using this for anything serious. i strongly recommend against using this for anything serious.
things that are missing that will not be added: things that are missing that will probably not be added:
- `InfoBox` is a bare-minimum implementation that doesn't use any of our styling - `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 because i didn't want to make tailwind a dependency here
@@ -26,7 +26,7 @@ things that are here that will probably be removed at some point:
- extraneous IDs, types, etc. this was pulled straight out of the cohost - extraneous IDs, types, etc. this was pulled straight out of the cohost
codebase and while i removed the obviously unnecessary bits (things related to codebase and while i removed the obviously unnecessary bits (things related to
invites, for example) there might be some bonus bullshit in here. invites, for example) there might be some bonus bullshit in here.
- code related to AST caching. it's entirely unnecessary for non-production use. - ~~code related to AST caching. it's entirely unnecessary for non-production use.~~ it's gone now
# license # license

View File

@@ -7,11 +7,10 @@ import {
summaryContent, summaryContent,
ViewBlock, ViewBlock,
} from "../types/post-blocks"; } from "../types/post-blocks";
import { PostASTMap, WirePostViewModel } from "../types/wire-models"; import { WirePostViewModel } from "../types/wire-models";
import { compile } from "html-to-text"; import { compile } from "html-to-text";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import React, { createElement, Fragment, JSX } from "react"; import React, { createElement, Fragment, JSX } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import rehypeExternalLinks from "rehype-external-links"; import rehypeExternalLinks from "rehype-external-links";
import rehypeRaw from "rehype-raw"; import rehypeRaw from "rehype-raw";
import rehypeReact from "rehype-react"; import rehypeReact from "rehype-react";
@@ -28,10 +27,8 @@ import { chooseAgeRuleset } from "./sanitize";
import { MAX_GFM_LINES, RenderingOptions } from "./shared-types"; import { MAX_GFM_LINES, RenderingOptions } from "./shared-types";
import { import {
cleanUpFootnotes, cleanUpFootnotes,
compileHastAST,
convertMentions, convertMentions,
copyImgAltToTitle, copyImgAltToTitle,
parseHastAST,
} from "./unified-processors"; } from "./unified-processors";
import _ from "lodash"; import _ from "lodash";
import type { Root as HASTRoot } from "hast"; import type { Root as HASTRoot } from "hast";
@@ -75,16 +72,9 @@ const markdownRenderStack = (
} }
return stack return stack
.use( .use(remarkRehype, {
remarkRehype as Plugin<
[{ allowDangerousHtml: boolean }],
MDASTRoot,
HASTRoot
>,
{
allowDangerousHtml: true, allowDangerousHtml: true,
} })
)
.use(copyImgAltToTitle) .use(copyImgAltToTitle)
.use(() => cleanUpFootnotes) .use(() => cleanUpFootnotes)
.use(rehypeRaw) .use(rehypeRaw)
@@ -101,13 +91,12 @@ const ERROR_BOX_NODE = (
</p> </p>
</InfoBox> </InfoBox>
); );
const ERROR_BOX_HTML = renderToStaticMarkup(ERROR_BOX_NODE);
async function renderMarkdownAst( async function renderMarkdownToReact(
blocks: MarkdownViewBlock[], blocks: MarkdownViewBlock[],
publishDate: Date, publishDate: Date,
options: Pick<RenderingOptions, "hasCohostPlus" | "renderingContext"> options: RenderingOptions
): Promise<string> { ): Promise<JSX.Element> {
const src = blocks.map((block) => block.markdown.content).join("\n\n"); const src = blocks.map((block) => block.markdown.content).join("\n\n");
let lineLength = 0; let lineLength = 0;
@@ -128,45 +117,15 @@ async function renderMarkdownAst(
); );
} }
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"]; const externalRel = ["nofollow"];
if (options.externalLinksInNewTab) { if (options.externalLinksInNewTab) {
externalRel.push("noopener"); externalRel.push("noopener");
} }
try { try {
return ( const result = await markdownRenderStack(publishDate, lineLength, options)
stack .use(() => convertMentions)
.use(parseEmoji, { cohostPlus: options.hasCohostPlus })
.use(rehypeExternalLinks, { .use(rehypeExternalLinks, {
rel: externalRel, rel: externalRel,
target: options.externalLinksInNewTab ? "_blank" : "_self", target: options.externalLinksInNewTab ? "_blank" : "_self",
@@ -180,9 +139,13 @@ export function renderReactFromAst(
CustomEmoji, CustomEmoji,
}, },
}) })
.processSync(astString).result .process(src);
);
return result.result;
} catch (e) { } 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 ERROR_BOX_NODE; return ERROR_BOX_NODE;
} }
} }
@@ -275,11 +238,20 @@ export function renderPostSummary(
return summary; return summary;
} }
export async function generatePostAst( export type PostRenderResult = {
spans: Array<{
startIndex: number;
endIndex: number;
rendered: JSX.Element;
}>;
readMoreIndex: number | null;
};
export async function renderPostBlocks(
viewBlocks: ViewBlock[], viewBlocks: ViewBlock[],
publishDate: Date, publishDate: Date,
options: Pick<RenderingOptions, "hasCohostPlus" | "renderingContext"> options: RenderingOptions
): Promise<PostASTMap> { ): Promise<PostRenderResult> {
// identify markdown spans // identify markdown spans
const spans: { const spans: {
startIndex: number; startIndex: number;
@@ -290,9 +262,8 @@ export async function generatePostAst(
for (let i = 0; i < viewBlocks.length; i++) { for (let i = 0; i < viewBlocks.length; i++) {
const block = viewBlocks[i]; const block = viewBlocks[i];
const isMarkdownBlock = isMarkdownViewBlock(block);
if (isMarkdownBlock) { if (isMarkdownViewBlock(block)) {
if ( if (
currentSpanStartIndex !== null && currentSpanStartIndex !== null &&
block.markdown.content === "---" && block.markdown.content === "---" &&
@@ -336,17 +307,16 @@ export async function generatePostAst(
}); });
} }
// render each span and return AST map // render each span and return rendered React elements
return { return {
spans: await Promise.all( spans: await Promise.all(
spans.map(async (span) => ({ spans.map(async (span) => ({
startIndex: span.startIndex, startIndex: span.startIndex,
endIndex: span.endIndex, endIndex: span.endIndex,
ast: await renderMarkdownAst( rendered: await renderMarkdownToReact(
viewBlocks.slice( viewBlocks
span.startIndex, .slice(span.startIndex, span.endIndex)
span.endIndex .filter(isMarkdownViewBlock),
) as MarkdownViewBlock[],
publishDate, publishDate,
options options
), ),
@@ -358,10 +328,7 @@ export async function generatePostAst(
// interim rendering method for until we get the rest of the inline attachments // interim rendering method for until we get the rest of the inline attachments
// changes done. render a sequence of markdown spans all joined together. // changes done. render a sequence of markdown spans all joined together.
export function renderReactFromSpans( export function renderReactFromSpans(spans: PostRenderResult["spans"]) {
spans: PostASTMap["spans"],
options: Omit<RenderingOptions, "hasCohostPlus">
) {
const rendered: JSX.Element[] = []; const rendered: JSX.Element[] = [];
for (let i = 0; i < spans.length; i++) { for (let i = 0; i < spans.length; i++) {
@@ -375,7 +342,7 @@ export function renderReactFromSpans(
rendered.push( rendered.push(
<React.Fragment key={`span-${spans[i].startIndex}`}> <React.Fragment key={`span-${spans[i].startIndex}`}>
{renderReactFromAst(spans[i].ast, options)} {spans[i].rendered}
</React.Fragment> </React.Fragment>
); );
} }

View File

@@ -1,7 +1,6 @@
import type { Element, Root, Text } from "hast"; import type { Element, Root, Text } from "hast";
import _ from "lodash"; import _ from "lodash";
import { Plugin } from "unified"; import { Plugin } from "unified";
import type { Compiler, Parser } from "unified";
import { is } from "unist-util-is"; import { is } from "unist-util-is";
import { CONTINUE, SKIP, visit } from "unist-util-visit"; import { CONTINUE, SKIP, visit } from "unist-util-visit";
import { EXIT, visitParents } from "unist-util-visit-parents"; import { EXIT, visitParents } from "unist-util-visit-parents";
@@ -159,20 +158,3 @@ export const copyImgAltToTitle: Plugin<[], Root> = () => (hast: Root) => {
} }
}); });
}; };
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

@@ -103,23 +103,6 @@ export const LimitedVisibilityReason = z.enum([
]); ]);
export type LimitedVisibilityReason = z.infer<typeof LimitedVisibilityReason>; 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 // double declaration required due to a typescript limitation with recursive types
// see: https://github.com/colinhacks/zod#recursive-types // see: https://github.com/colinhacks/zod#recursive-types
type WirePostViewModelInternal = WirePostContentCommon & { type WirePostViewModelInternal = WirePostContentCommon & {
@@ -141,7 +124,6 @@ type WirePostViewModelInternal = WirePostContentCommon & {
canShare: boolean; canShare: boolean;
canPublish: boolean; canPublish: boolean;
limitedVisibilityReason: LimitedVisibilityReason; limitedVisibilityReason: LimitedVisibilityReason;
astMap: PostASTMap;
responseToAskId: AskId | null; responseToAskId: AskId | null;
}; };
@@ -165,7 +147,6 @@ export const WirePostViewModel: z.ZodType<WirePostViewModelInternal> = z.lazy(
canShare: z.boolean(), canShare: z.boolean(),
canPublish: z.boolean(), canPublish: z.boolean(),
limitedVisibilityReason: LimitedVisibilityReason, limitedVisibilityReason: LimitedVisibilityReason,
astMap: PostASTMap,
responseToAskId: AskId.nullable(), responseToAskId: AskId.nullable(),
}) })
); );