Skip to content

WIP Chat 2 #389

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 29 commits into
base: next
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -2,10 +2,7 @@
"private": true,
"workspaces": {
"packages": [
"packages/mdx",
"examples/*",
"playground",
"site"
"packages/mdx"
]
},
"scripts": {
5 changes: 3 additions & 2 deletions packages/mdx/package.json
Original file line number Diff line number Diff line change
@@ -44,8 +44,9 @@
"coverage": "vitest run --coverage"
},
"dependencies": {
"@code-hike/lighter": "0.7.0",
"node-fetch": "^2.0.0"
"@code-hike/lighter": "0.7.1",
"node-fetch": "^2.0.0",
"react-markdown": "^8.0.7"
},
"devDependencies": {
"@codesandbox/sandpack-client": "^0.19.0",
220 changes: 220 additions & 0 deletions packages/mdx/pages/chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import React from "react"
import { Chat } from "../src/chat/chat"
import { useConversation } from "../src/chat/use-conversation"
import theme from "@code-hike/lighter/themes/github-dark.json"
import { Message } from "../src/chat/types"
import { setMetadata } from "../src/chat/metadata"

export default function Page() {
const [progress, setProgress] = React.useState(max)
const messages = steps[progress]
const isStreaming =
messages[messages.length - 1]?.isStreaming
const conversation = useConversation(
messages,
!!isStreaming,
x => {
console.log(x)
},
theme
)
// console.log(progress)
// console.log({ messages })
// console.log(messages[messages.length - 1]?.content)
// console.log({ conversation })

React.useEffect(() => {
// focus input range
const input = document.querySelector(
"input[type=range]"
) as HTMLInputElement
input.focus()
}, [])

const preRef = React.useRef<HTMLPreElement>(null)

React.useEffect(() => {
if (preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight
}
}, [progress])

return (
<div>
<style jsx global>{`
html,
body,
body > div:first-child,
div#__next,
div#__next > div {
background: #ccc !important;
color: #fff;
}
.ch-chat {
width: 900px;
margin: 0 auto;
}
.ch-scrollycoding-sticker {
}
`}</style>
<Chat
conversation={conversation}
height="80vh"
theme={theme as any}
/>
<div
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
height: "20vh",
padding: "2px 0 8px",
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
}}
>
<pre
ref={preRef}
style={{
width: "100%",
flex: 1,
background: "black",
color: "white",
overflow: "auto",
// opacity: 0,
}}
>
{messages[messages.length - 1]?.content}
</pre>
<input
autoFocus
style={{ width: "100%", padding: 0, margin: 0 }}
type="range"
min="0"
max={max}
value={progress}
onChange={e =>
setProgress(Number(e.target.value))
}
onKeyDown={event => {
if (
event.key === "ArrowRight" &&
progress < 100
) {
setProgress(prevValue =>
Math.min(prevValue + 1, max)
)
} else if (
event.key === "ArrowLeft" &&
progress > 0
) {
setProgress(prevValue =>
Math.max(prevValue - 1, 0)
)
}
}}
/>
</div>
</div>
)
}

const messages = [
{
role: "user",
content:
"---\nmodel: gpt-3.5-turbo-0301\n---\nremove margin from first and last child",
},
{
role: "assistant",
content:
"```css style.css\n.parent > *:first-child {\n margin-top: 0;\n}\n\n.parent > *:last-child {\n margin-bottom: 0;\n}\n```\n\n\nUse the `:first-child` and `:last-child` pseudo-classes to target the first and last child elements respectively. Then set their `margin-top` and `margin-bottom` properties to `0`.\n\nDoes this solve your problem?\n\n---\n\n- Yes, thank you!\n- Can I use this with other properties like padding?\n- What if the first/last child already has a margin?",
},
{
role: "user",
content: "---\nmodel: gpt-3.5-turbo-0301\n---\nhtml",
},
{
role: "assistant",
content:
'```html index.html\n<div class="parent">\n <div>First child</div>\n <div>Second child</div>\n <div>Last child</div>\n</div>\n```\n\n\nThis is an example of a parent `div` element with three child `div` elements. The first child has the text "First child", the second child has the text "Second child", and the last child has the text "Last child".\n\nIs there anything else you need help with?\n\n---\n\n- No, that\'s all for now.',
},
{
role: "user",
content:
"---\nmodel: gpt-3.5-turbo-0301\n---\nwith flex",
},
{
role: "assistant",
content:
"```css style.css\n.parent {\n display: flex;\n flex-direction: column;\n}\n\n.parent > *:first-child {\n margin-top: 0;\n}\n\n.parent > *:last-child {\n margin-bottom: 0;\n}\n```\n\n\nThis code sets the parent element to a flex container with a column direction. Then, it uses the `:first-child` and `:last-child` pseudo-classes to remove the top margin from the first child and the bottom margin from the last child.\n\nDoes this help you with your issue?\n\n---\n\n- Yes, thank you!\n- Can I use this with other flex properties?\n- What if I want to remove the margin from the second child?",
},
{
role: "user",
content:
"---\nmodel: gpt-3.5-turbo-0301\n---\nCan I use this with other flex properties?",
},
{
content:
"```css style.css\n.parent {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n}\n\n.parent > *:first-child {\n margin-top: 0;\n}\n\n.parent > *:last-child {\n margin-bottom: 0;\n}\n```\n\n\nYes, you can use this code with other flex properties. In this example, the `justify-content` and `align-items` properties are set to `center` to center the child elements both horizontally and vertically.\n\nIs there anything else you need help with?\n\n---\n\n- No, that's all for now.",
role: "assistant",
},
]
.map(m => ({
...m,
content: m.content.replace(/~/g, "`"),
}))
.map(m =>
m.role === "assistant"
? m
: {
...m,
content: setMetadata(m.content, {
model: "GPT-3",
}),
}
)

function getSteps() {
const steps = [[]] as (Message & {
isStreaming?: boolean
})[][]
const current = [] as Message[]
messages.forEach(m => {
if (m.role === "user") {
current.push(m)
steps.push([...current])
return
}

let newContent = ""
steps.push([
...current,
{ ...m, content: newContent, isStreaming: true },
])

const splits = m.content.match(/.{1,2}/gs) || []
splits.forEach(s => {
newContent += s
steps.push([
...current,
{ ...m, content: newContent, isStreaming: true },
])
})

steps.push([
...current,
{ ...m, content: newContent, isStreaming: false },
])

current.push(m)
})
return steps
}

const steps = getSteps()
const max = steps.length - 1
1 change: 1 addition & 0 deletions packages/mdx/rollup.config.js
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@ import banner2 from "rollup-plugin-banner2"
const clientExternal = [
"react",
"react-dom",
"react-markdown",
// "@codesandbox/sandpack-client",
// "use-spring",
// "diff",
174 changes: 174 additions & 0 deletions packages/mdx/src/chat/__snapshots__/answer-parser.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Vitest Snapshot v1

exports[`
\`\`\`js foo.js
console.log(1)
1`] = `
{
"content": "",
"fileInfoList": [
{
"lang": "js",
"name": "foo.js",
"open": true,
"text": "console.log(1)
",
},
],
"replies": [],
}
`;

exports[`
\`\`\`js foo.js
console.log(1)
\`\`\`
\`\`\`ts bar.ts
console.log(2)
\`\`\`
# hello
world 1`] = `
{
"content": "# hello
world",
"fileInfoList": [
{
"lang": "js",
"name": "foo.js",
"open": false,
"text": "console.log(1)
",
},
{
"lang": "ts",
"name": "bar.ts",
"open": false,
"text": "console.log(2)",
},
],
"replies": [],
}
`;

exports[`
\`\`\`js foo.js
console.log(1)
\`\`\`
# hello
world
---
- yes
- no
1`] = `
{
"content": "# hello
world",
"fileInfoList": [
{
"lang": "js",
"name": "foo.js",
"open": false,
"text": "console.log(1)",
},
],
"replies": [
"yes",
"no",
],
}
`;

exports[` 1`] = `
{
"content": "",
"fileInfoList": [],
"replies": [],
}
`;

exports[`\`\`\`js foo.js
console.log(1)
\`\`\`
Hello
- 1`] = `
{
"content": "Hello
",
"fileInfoList": [
{
"lang": "js",
"name": "foo.js",
"open": false,
"text": "console.log(1)",
},
],
"replies": [],
}
`;

exports[`\`\`\`js foo.js
console.log(1)
\`\`\`
Hello
---
1`] = `
{
"content": "Hello
",
"fileInfoList": [
{
"lang": "js",
"name": "foo.js",
"open": false,
"text": "console.log(1)",
},
],
"replies": [],
}
`;

exports[`\`\`\`js foo.js
console.log(1)
\`\`\`
Hello
--- 1`] = `
{
"content": "Hello
",
"fileInfoList": [
{
"lang": "js",
"name": "foo.js",
"open": false,
"text": "console.log(1)",
},
],
"replies": [],
}
`;

exports[`Hello 1`] = `
{
"content": "Hello",
"fileInfoList": [],
"replies": [],
}
`;
23 changes: 23 additions & 0 deletions packages/mdx/src/chat/__snapshots__/metadata.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Vitest Snapshot v1

exports[`add 1`] = `
"---
foo: bar
x: y
---
hello"
`;

exports[`change 1`] = `
"---
foo: y
---
hello"
`;

exports[`set 1`] = `
"---
foo: bar
---
hello"
`;
67 changes: 67 additions & 0 deletions packages/mdx/src/chat/answer-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { expect, test } from "vitest"
import { parseAnswer } from "./answer-parser"

const fixtures = [
``,
`Hello`,
`
~~~js foo.js
console.log(1)
~~~
# hello
world
---
- yes
- no
`,
`
~~~js foo.js
console.log(1)
~~~
~~~ts bar.ts
console.log(2)
~~~
# hello
world`,
`
~~~js foo.js
console.log(1)
`,
`~~~js foo.js
console.log(1)
~~~
Hello
-`,
`~~~js foo.js
console.log(1)
~~~
Hello
---`,
,
`~~~js foo.js
console.log(1)
~~~
Hello
---
`,
].map(x => x.replace(/~/g, "`"))

test.each(fixtures)("%s", async markdown => {
expect(parseAnswer(markdown, true)).toMatchSnapshot()
})
100 changes: 100 additions & 0 deletions packages/mdx/src/chat/answer-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// turns streaming markdown into codeblocks, content and replies
// doesnt care about previous answers
export function parseAnswer(
markdown: string,
isStreaming: boolean
) {
const { markdownWithoutCode, fileInfoList } =
extractCodeBlocks(markdown, isStreaming)
const [answerText, repliesText] =
markdownWithoutCode.split(/\n+---\n+/)
const replies = repliesText
? repliesText
.split(/\n/)
.map((r: string) => r.replace(/^-\s*/, "").trim())
.filter((r: any) => r !== "")
: []

return {
fileInfoList: fileInfoList,
content: answerText.replace(/\n-+$/, ""),
replies,
}
}

function extractCodeBlocks(
markdown: string,
isStreaming: boolean
) {
const closedCodeBlocks =
markdown.match(/```[\s\S]*?```/g) || []
const markdownWithoutClosedCodeBlocs = markdown.replace(
/```[\s\S]*?```/g,
""
)

const fileInfoList = closedCodeBlocks.map(s => ({
...codeblockToFileInfo(s),
open: false,
}))

let markdownWithoutCode = markdownWithoutClosedCodeBlocs

if (isStreaming) {
const openCodeBlock =
markdownWithoutClosedCodeBlocs.match(/```[\s\S]*?$/g)

if (openCodeBlock) {
markdownWithoutCode =
markdownWithoutClosedCodeBlocs.replace(
/```[\s\S]*?$/g,
""
)

let streamingCodeBlock = openCodeBlock[0]
.split("\n")
.slice(0, -1)
.join("\n")

if (streamingCodeBlock) {
fileInfoList.push({
...codeblockToFileInfo(
streamingCodeBlock + "\n```"
),
open: isStreaming,
})
}
}
}

// if ends with ` or ``, let's wait until more chars
const backticks = markdownWithoutCode.match(/(`$|``$)/g)
if (backticks) {
markdownWithoutCode = markdownWithoutCode.replace(
/(`$|``$)/g,
""
)
}

markdownWithoutCode = markdownWithoutCode.trim()
// console.log({ markdownWithoutCode })

return { markdownWithoutCode, fileInfoList }
}

function codeblockToFileInfo(codeblock: string): {
lang: string
name: string
text: string
} {
const codeBlockPattern =
/```([^ \n]+)? ?([^\n]+)?\n([\s\S]*?)\n?```/g

const match = codeBlockPattern.exec(codeblock)

return {
lang: match?.[1] || "",
name: match?.[2] || "answer",
text: match?.[3] || "",
}
}
148 changes: 148 additions & 0 deletions packages/mdx/src/chat/chat.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
@import "../utils/mixins.scss";

.ch-chat-static {
padding: 1em;

& > * {
margin-top: 1.25em;
margin-bottom: 1.25em;
}
}

.ch-chat {
gap: 10px;

--ch-scrollycoding-sticker-width: 520px;
transition: transform 0.3s cubic-bezier(0.42, 0, 0.21, 1);

&.ch-chat-no-code {
transform: translateX(
calc(
-0.5 * var(--ch-scrollycoding-sticker-width) - 5px
)
);

.ch-scrollycoding-sticker {
opacity: 0;
}
}

.ch-scrollycoding-sticker {
opacity: 1;
transition: opacity 0.6s
cubic-bezier(1, -0.01, 0.68, 1.01);
}

@keyframes bounce {
0%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-5px);
}
}

.ch-scrollycoding-content {
// padding: 80vh 0 140px !important;
font-size: 1rem;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 10px;
}

.ch-scrollycoding-step-content {
padding: 16px !important;
margin: 0px !important;
/* min-height: 50vh !important; */
min-height: 10px !important;
}
.ch-scrollycoding-step-content[data-selected] {
border-color: transparent !important;
/* background: rgba(189, 147, 249, 0.243); */
}
.ch-scrollycoding-step-content[data-selected] .answer {
outline: rgb(191, 158, 238) 4px solid;
}
}

.ch-chat-message {
padding: 16px !important;
border-radius: 16px;
color: white;
line-height: 1.5;
}

.ch-chat-message p {
margin: 1.5rem 0;
}

.ch-chat-message p:last-child {
margin-bottom: 0;
}

.ch-chat-message p:first-child {
margin-top: 0;
}

.ch-chat-question {
background-color: #0084ff;
border-bottom-right-radius: 3px;
}

.ch-chat-answer {
background-color: rgb(47, 51, 54);
border-top-left-radius: 3px;
}

.ch-chat-answer:last-child .ch-chat-replies {
display: flex;
}

.ch-chat-replies {
display: none;
flex-direction: column;
gap: 8px;
align-items: flex-end;
}

.ch-chat-reply {
@include button-reset;
background-color: #222;
border: 1px solid #555;
color: #e7f3ff;
padding: 8px 10px;
border-radius: 8px;
border-bottom-right-radius: 0;
font-size: 0.9rem;
display: inline-block;
width: fit-content;
}

.ch-chat-reply:hover {
background-color: #333;
color: #fafafa;
}

.ch-chat-bouncing-dots {
display: flex;
align-items: center;
}

.ch-chat-bouncing-dots div {
background-color: currentColor;
width: 7px;
height: 7px;
border-radius: 50%;
margin: 0 3px;
animation: bounce 1.8s infinite;

&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
243 changes: 243 additions & 0 deletions packages/mdx/src/chat/chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import React from "react"
import { InnerCode } from "../mdx-client/code"
import {
AnswerEntry,
ConversationEntry,
EntryCodeFile,
} from "./types"
import { Swap } from "mdx-client/ssmq"
import { RawTheme } from "@code-hike/lighter/dist/browser.esm.mjs"
import { getCSSVariablesObjectSync } from "utils/theme"

type ChatProps = {
staticMediaQuery?: string
height: React.CSSProperties["height"]
conversation: ConversationEntry[]
theme: RawTheme
} & React.HTMLAttributes<HTMLElement>

export function Chat({
staticMediaQuery = "not screen, (max-width: 930px)",
theme,
style,
...props
}: ChatProps) {
const cssVariables = React.useMemo(
() => getCSSVariablesObjectSync(theme),
[theme]
)
const newStyle = { ...style, ...cssVariables }

return (
<Swap
match={[
[
staticMediaQuery,
<StaticChat style={newStyle} {...props} />,
],
[
"default",
<DynamicChat style={newStyle} {...props} />,
],
]}
/>
)
}

function StaticChat({
conversation,
height,
...props
}: Omit<ChatProps, "staticMediaQuery" | "theme">) {
return (
<section {...props} className="ch-chat-static">
{conversation.map((entry, i) =>
entry.type === "answer" ? (
<React.Fragment key={i}>
<Code
key={
entry.files?.find(
f => f.name === entry.activeFile
)?.text
}
files={entry.files}
activeFile={entry.activeFile}
/>
<div className="ch-chat-message ch-chat-answer">
{entry.children}
</div>
</React.Fragment>
) : entry.type === "question" ? (
<div
key={i}
className="ch-chat-message ch-chat-question"
>
{entry.children}
</div>
) : (
<React.Fragment key={i}>
{entry.children}
</React.Fragment>
)
)}
</section>
)
}

function DynamicChat({
conversation,
height,
...props
}: Omit<ChatProps, "staticMediaQuery" | "theme">) {
const hasCode = conversation.some(
s => s.type === "answer" && s.files?.length
)
const contentRef = React.useRef<HTMLDivElement>(null)
const stickerRef = React.useRef<HTMLDivElement>(null)
React.useLayoutEffect(() => {
if (stickerRef.current && contentRef.current) {
alignBottoms(contentRef.current, stickerRef.current)
}
}, [conversation])

const answers = conversation.filter(
c => c.type === "answer"
)
const lastAnswer = answers[answers.length - 1] as any
const files = lastAnswer?.files

return (
<section
{...props}
className={
"ch-scrollycoding ch-chat " +
(hasCode ? "" : "ch-chat-no-code")
}
>
<div
className="ch-scrollycoding-sticker"
style={{ height }}
ref={stickerRef}
>
<Code
files={files}
activeFile={lastAnswer?.activeFile}
/>
</div>

<div
className="ch-scrollycoding-content"
style={{ minHeight: height }}
ref={contentRef}
>
{conversation.map((kid, i) =>
kid.type === "answer" ? (
<div
className="ch-scrollycoding-step-content ch-chat-message ch-chat-answer"
key={i}
>
{kid.children}
</div>
) : kid.type === "question" ? (
<div
className="ch-scrollycoding-step-content ch-chat-message ch-chat-question"
key={i}
>
{kid.children}
</div>
) : (
<div
className="ch-scrollycoding-step-content"
key={i}
>
{kid.children}
</div>
)
)}
</div>
</section>
)
}

function alignBottoms(
stickerElement: HTMLElement,
contentElement: HTMLElement
) {
const stickerElementRect =
stickerElement.getBoundingClientRect()
const stickerElementBottom = stickerElementRect.bottom

const lastStepElementRect =
contentElement.getBoundingClientRect()
const lastStepElementHeight = lastStepElementRect.height

const targetScrollTop =
stickerElementBottom - lastStepElementHeight

const currentScrollTop = window.pageYOffset
const newWindowScrollTop =
currentScrollTop +
(targetScrollTop - lastStepElementRect.top)

window.scrollTo({
top: newWindowScrollTop,
})
}

const Code = React.memo(
({
files,
activeFile,
}: {
files: EntryCodeFile[]
activeFile?: string
}) => {
const [active, setActiveFile] =
React.useState(activeFile)

React.useEffect(() => {
setActiveFile(activeFile)
}, [activeFile])

if (!files || !files.length) return null

// console.log("render code", files, activeFile)

const raw = files.find(f => f.name === active)?.raw
// console.log(
// raw,
// files.find(f => f.name === active)
// )
return (
<InnerCode
key={raw?.toString()}
codeConfig={{
showCopyButton: true,
showExpandButton: true,
}}
rows="focus"
northPanel={{
tabs: files.map(f => f.name),
active: active || activeFile,
heightRatio: 1,
}}
files={files}
style={{ height: "100%" }}
onTabClick={tab => {
setActiveFile(tab)
}}
/>
)
},
(prevProps, nextProps) => {
return (
prevProps.activeFile === nextProps.activeFile &&
prevProps.files?.length === nextProps.files?.length &&
prevProps.files?.every(
(f, i) =>
f.text === nextProps.files[i]?.text &&
f.raw === nextProps.files[i]?.raw
)
)
}
)
142 changes: 142 additions & 0 deletions packages/mdx/src/chat/highlight-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {
highlightSync,
RawTheme,
} from "@code-hike/lighter/dist/browser.esm.mjs"
import { isLangLoaded, isLangSupported } from "./languages"
import { EntryCodeFile, FileInfo } from "./types"

import { diffLines } from "diff"

export function highlightFile(
fileInfo: FileInfo,
theme: RawTheme,
prevFile?: EntryCodeFile
): EntryCodeFile {
if (!isLangLoaded(fileInfo.lang)) {
return {
name: fileInfo.name,
code: {
lines: [
{
tokens: [
{
content: ".",
props: {
style: { color: "transparent" },
},
},
],
},
],
lang: fileInfo.lang,
},
text: "",
raw: true,
focus: "",
annotations: [],
}
}

const text = fileInfo.open ? getStreamingCode(
fileInfo.text,
prevFile?.text
) : fileInfo.text


const result =
isLangSupported(fileInfo.lang) &&
isLangLoaded(fileInfo.lang)
? highlightSync(text, fileInfo.lang, theme)
: highlightSync(text, "text", theme)
const lines = result.lines.map(line => ({
tokens: line.map(token => ({
content: token.content,
props: { style: token.style },
})),
}))

return {
name: fileInfo.name,
code: {
lines,
lang: fileInfo.lang,
},
text,
focus: getDiffFocus(prevFile?.text || "", text),
raw: !isLangLoaded(fileInfo.lang),
annotations: [],
}
}

function getStreamingCode(
streamingCode: string,
oldCode?: string
) {
if (!streamingCode) return oldCode || ""
const oldLines = oldCode?.split("\n") ?? []
const newLines = streamingCode.split("\n")
const lines = getLines(oldLines, newLines)
return lines.join("\n")
}

function getLines(
oldLines: string[],
newLines: string[]
): string[] {
if (oldLines.length === 0) return newLines
if (newLines.length === 0) return oldLines

const [firstOldLine, ...restOldLines] = oldLines
const [firstNewLine, ...restNewLines] = newLines
if (firstOldLine === firstNewLine) {
// same line
return [
firstNewLine,
...getLines(restOldLines, restNewLines),
]
}

const index = restOldLines.findIndex(
l => l === firstNewLine
)
if (index !== -1 && firstNewLine.trim().length > 1) {
// deleted index+1 lines
const newRestOldLines = restOldLines.slice(index + 1)
return [
firstNewLine,
...getLines(newRestOldLines, restNewLines),
]
}

// added line
return [firstNewLine, ...getLines(oldLines, restNewLines)]
}

function getDiffFocus(
oldString: string,
newString: string
) {
const diff = diffLines(oldString, newString)

let newLineIndexes = []
let lineCount = 1

diff.forEach(part => {
if (part.removed) return

if (!part.added) {
lineCount += part.count
return
}

let i = 0
while (i < part.count) {
newLineIndexes.push(lineCount + i)
i++
}
lineCount += part.count
})
const focus = newLineIndexes.join(",")

return focus
}
45 changes: 45 additions & 0 deletions packages/mdx/src/chat/languages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
preload,
LANG_NAMES,
} from "@code-hike/lighter/dist/browser.esm.mjs"

const cache = new Map<
string,
{ promise: Promise<boolean>; loaded: boolean }
>()

export function getLoadedLanguages() {
return Array.from(cache.entries())
.filter(([, { loaded }]) => loaded)
.map(([lang]) => lang)
}

export function isLangSupported(lang: string) {
return LANG_NAMES.includes(lang)
}

export function isLangLoaded(lang: string) {
if (!isLangSupported(lang)) {
return true
}

// if is server side return false
if (typeof window === "undefined") {
return false
}

return cache.get(lang)?.loaded
}

export async function loadLang(lang: string) {
// TODO what happens if the lang is not supported?
if (cache.has(lang)) {
return cache.get(lang)!.promise
}

const promise = preload([lang])
cache.set(lang, { promise, loaded: false })
await promise
cache.set(lang, { promise, loaded: true })
return promise
}
20 changes: 20 additions & 0 deletions packages/mdx/src/chat/metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { expect, test } from "vitest"
import { setMetadata } from "./metadata"

test("set", () => {
expect(
setMetadata("hello", { foo: "bar" })
).toMatchSnapshot()
})

test("add", () => {
const content = setMetadata("hello", { foo: "bar" })
expect(setMetadata(content, { x: "y" })).toMatchSnapshot()
})

test("change", () => {
const content = setMetadata("hello", { foo: "bar" })
expect(
setMetadata(content, { foo: "y" })
).toMatchSnapshot()
})
43 changes: 43 additions & 0 deletions packages/mdx/src/chat/metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export function setMetadata(
content: string,
metadata: Record<string, string>
) {
const oldData = getMetadata(content)

const metadataString = Object.entries({
...oldData,
...metadata,
})
.map(([key, value]) => `${key}: ${value}`)
.join("\n")

const contentWithoutMetadata = removeMetadata(content)

return `---\n${metadataString}\n---\n${contentWithoutMetadata}`
}

export function getMetadata(
content: string
): Record<string, string> {
if (!content) {
return undefined
}
const metadata = content.match(/^---\n([\s\S]*?)\n---\n/)

if (!metadata) {
return {}
}

const metadataString = metadata[1]

const metadataEntries = metadataString
.split("\n")
.map(s => s.split(": "))
.filter(([key, value]) => key && value)

return Object.fromEntries(metadataEntries)
}

export function removeMetadata(content: string) {
return content.replace(/^---\n[\s\S]*?\n---\n/, "")
}
35 changes: 35 additions & 0 deletions packages/mdx/src/chat/replies.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import ReactMarkdown from "react-markdown"
import React from 'react'

export function Replies({
replies,
onReply,
}: {
replies: string[]
onReply: (reply: string) => void
}) {
if (!replies || replies.length === 0) return null
return (
<div className="ch-chat-replies">
{replies.map((reply, i) => (
<button
key={i}
onClick={() => onReply(reply)}
className="ch-chat-reply"
>
<ReactMarkdown>{reply}</ReactMarkdown>
</button>
))}
</div>
)
}

export const BouncingDots = () => {
return (
<div className="ch-chat-bouncing-dots">
<div />
<div />
<div />
</div>
)
}
34 changes: 34 additions & 0 deletions packages/mdx/src/chat/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { CodeFile } from "mini-editor"

export type ConversationEntry =
| {
type: "question" | "other"
children: React.ReactNode
metadata?: Record<string, string>
}
| AnswerEntry

export type AnswerEntry = {
type: "answer"
files: EntryCodeFile[]
activeFile?: string
children: React.ReactNode
metadata?: Record<string, string>
}

export type Message = {
role: "user" | "assistant" | string
content: string
}

export type FileInfo = {
name: string
lang: string
text: string
open: boolean
}

export type EntryCodeFile = CodeFile & {
text: string
raw?: boolean
}
268 changes: 268 additions & 0 deletions packages/mdx/src/chat/use-conversation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import ReactMarkdown from "react-markdown"
import { parseAnswer } from "./answer-parser"
import { BouncingDots, Replies } from "./replies"
import {
AnswerEntry,
ConversationEntry,
EntryCodeFile,
FileInfo,
Message,
} from "./types"
import React from "react"
import { RawTheme } from "@code-hike/lighter/dist/browser.esm.mjs"
import {
getLoadedLanguages,
isLangLoaded,
loadLang,
} from "./languages"
import { highlightFile } from "./highlight-code"
import { getMetadata, removeMetadata } from "./metadata"

export function useConversation(
messages: Message[],
isStreaming: boolean,
onReply: (reply: string) => void,
theme: RawTheme
): ConversationEntry[] {
const ref = React.useRef({
oldConversation: [] as ConversationEntry[],
})

const [loadingLangs, setLoadingLangs] = React.useState(
[] as string[]
)

const { oldConversation } = ref.current

const lastMessage = messages[messages.length - 1]
let newMessages = messages
if (lastMessage?.role === "user") {
newMessages = [
...messages,
{
role: "assistant",
content: "",
},
]
}

let newConversation = getNewConversation(
[],
newMessages,
oldConversation,
isStreaming,
onReply,
theme
)

const missingLangs = [] as string[]
for (const entry of newConversation) {
if (entry.type === "answer") {
for (const file of entry.files) {
if (
!isLangLoaded(file.code.lang) &&
!missingLangs.includes(file.code.lang) &&
!loadingLangs.includes(file.code.lang)
) {
missingLangs.push(file.code.lang)
}
}
}
}

React.useEffect(() => {
if (missingLangs.length === 0) return
missingLangs.forEach(lang => {
loadLang(lang).then(() => {
setLoadingLangs(prev =>
prev.includes(lang) ? prev : [...prev, lang]
)
})
})
}, [missingLangs.join(",")])

// newConversation.forEach(entry => {
// if (entry.type === "answer") {
// entry.files = entry.files?.filter(
// f => !missingLangs.includes(f.code.lang)
// )
// }
// })

if (!isStreaming && missingLangs.length > 0) {
newConversation = []
}

ref.current.oldConversation = newConversation

// console.log(messages, newConversation)
return newConversation
}

function getNewConversation(
oldMessages: Message[],
newMessages: Message[],
oldConversation: ConversationEntry[],
isStreaming: boolean,
onReply: (reply: string) => void,
theme: RawTheme
): ConversationEntry[] {
const headCount = Math.max(oldConversation.length - 2, 0)
const conversation = oldConversation.slice(0, headCount)

for (let i = headCount; i < newMessages.length; i++) {
const oldMessage = oldMessages[i]
const newMessage = newMessages[i]
const oldEntry = oldConversation[i]
const isStreamingEntry =
isStreaming && i === newMessages.length - 1
const newEntry = getEntry(
oldMessage,
newMessage,
oldEntry,
[...conversation],
onReply,
isStreamingEntry,
theme
)
if (newEntry) {
conversation.push(newEntry)
} else {
break
}
}

return conversation
}

function getEntry(
oldMessage: Message | undefined,
newMessage: Message | undefined,
oldEntry: ConversationEntry | undefined,
conversation: ConversationEntry[],
onReply: (v: string) => void,
isStreaming: boolean,
theme: RawTheme
): ConversationEntry | undefined {
if (!newMessage) return undefined
if (newMessage.role === "user") {
return {
type: "question",
children: (
<ReactMarkdown>
{removeMetadata(newMessage.content)}
</ReactMarkdown>
),
metadata: getMetadata(newMessage.content),
}
}

const parsedAnswer = parseAnswer(
newMessage.content,
isStreaming
)

const { files, activeFile } = getFiles(
parsedAnswer.fileInfoList,
oldEntry,
conversation,
theme
)

const lastEntry = conversation[conversation.length - 1]
return {
type: "answer",
files,
activeFile,
metadata: {
...lastEntry?.metadata,
...getMetadata(parsedAnswer.content),
},
children: (
<>
{parsedAnswer.content ? (
<ReactMarkdown>
{removeMetadata(parsedAnswer.content)}
</ReactMarkdown>
) : (
<BouncingDots />
)}

<Replies
replies={parsedAnswer.replies}
onReply={onReply}
/>
</>
),
}
}

function getFiles(
fileInfoList: FileInfo[],
oldEntry: ConversationEntry | undefined,
conversation: ConversationEntry[],
theme: RawTheme
): {
files: EntryCodeFile[]
activeFile: string
} {
// we need the files from the previous answer
// if it's a new file it goes first

// if there's an open file it is active
// else we keep the same from the prev convo
// else we set the first one

let prevFiles = [] as EntryCodeFile[]
let prevActiveFile = ""

if (oldEntry?.type === "answer") {
prevFiles = oldEntry.files
prevActiveFile = oldEntry.activeFile
} else {
const lastAnswer = [...conversation]
.reverse()
.find(entry => entry.type === "answer") as
| AnswerEntry
| undefined
prevFiles = lastAnswer?.files ?? []
prevActiveFile = lastAnswer?.activeFile ?? ""
}

const files = prevFiles.map(prevFile => {
const newFile = fileInfoList.find(
newFile => newFile.name === prevFile.name
)

if (!newFile || newFile.text === prevFile.text) {
return prevFile
}

const prevAnswer = [...conversation]
.reverse()
.find(entry => entry.type === "answer") as
| AnswerEntry
| undefined

const prevAnswerFile = prevAnswer?.files.find(
file => file.name === prevFile.name
)

return highlightFile(newFile, theme, prevAnswerFile)
})

fileInfoList.forEach(fileInfo => {
if (!files.find(file => file.name === fileInfo.name)) {
files.unshift(highlightFile(fileInfo, theme))
}
})

const activeFile = fileInfoList.find(
fileInfo => fileInfo.open
)?.name
return {
files,
activeFile:
activeFile || prevActiveFile || files[0]?.name,
}
}
12 changes: 12 additions & 0 deletions packages/mdx/src/components.tsx
Original file line number Diff line number Diff line change
@@ -8,15 +8,27 @@ import { Spotlight } from "./mdx-client/spotlight"
import { Scrollycoding } from "./mdx-client/scrollycoding"
import { CodeSlot, PreviewSlot } from "./mdx-client/slots"
import { Slideshow } from "./mdx-client/slideshow"
import { Chat } from "./chat/chat"
import { useConversation } from "./chat/use-conversation"
import {
annotationsMap,
Annotation,
} from "./mdx-client/annotations"
import { Preview } from "./mdx-client/preview"
import { InlineCode } from "./mdx-client/inline-code"
import type { MDXComponents } from "mdx/types"
import {
setMetadata,
removeMetadata,
getMetadata,
} from "./chat/metadata"

export {
Chat,
useConversation,
setMetadata,
removeMetadata,
getMetadata,
Code,
Section,
SectionLink,
1 change: 1 addition & 0 deletions packages/mdx/src/index.scss
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@
@import "./mdx-client/spotlight.scss";
@import "./mdx-client/scrollycoding.scss";
@import "./mdx-client/slideshow.scss";
@import "./chat/chat.scss";

.ch-codeblock,
.ch-codegroup,
2 changes: 1 addition & 1 deletion packages/mdx/src/mdx-client/code.tsx
Original file line number Diff line number Diff line change
@@ -66,7 +66,7 @@ export function InnerCode({
...props
}: EditorProps & {
onTabClick?: (filename: string) => void
} & Partial<CodeHikeConfig>) {
} & Partial<CodeConfig>) {
const { className, style, codeConfig, ...editorProps } =
mergeCodeConfig(props)

54 changes: 2 additions & 52 deletions packages/mdx/src/remark/transform.ts
Original file line number Diff line number Diff line change
@@ -12,7 +12,7 @@ import { JsxNode, SuperNode, visit } from "./nodes"
import { addConfigDefaults, CodeHikeConfig } from "./config"

import type { Node } from "unist"
import { getThemeColors } from "@code-hike/lighter"
import { getStyle } from "utils/theme"

const transforms = [
transformPreviews,
@@ -96,54 +96,6 @@ function getUsedCodeHikeComponentNames(
return usage
}

const styleCache = new Map<string, string>()

async function getStyle(
theme: CodeHikeConfig["theme"],
themeName: string
) {
if (styleCache.has(themeName)) {
return styleCache.get(themeName)
}
const rules = await getCSSVariables(theme)
const style = `[data-ch-theme="${themeName}"] \{ ${rules} \}`
styleCache.set(themeName, style)
return style
}

async function getCSSVariables(
theme: CodeHikeConfig["theme"]
) {
const themeColors = await getThemeColors(theme)

if (!themeColors || typeof themeColors !== "object") {
throw new Error(
"[Code Hike error] Unknown theme format"
)
}
let rules = ""
for (const [first, value] of Object.entries(
themeColors
)) {
if (!value) {
continue
}
if (typeof value === "string") {
rules += `--ch-t-${first}: ${value};`
} else {
for (const [second, svalue] of Object.entries(
value
)) {
if (!svalue) {
continue
}
rules += `--ch-t-${first}-${second}: ${svalue};`
}
}
}
return rules
}

/**
* Creates a `chCodeConfig` variable node in the tree
* so that the components can access the config
@@ -155,9 +107,7 @@ async function addConfig(
config: CodeHikeConfig
) {
const { theme } = config
const themeName =
typeof theme === "string" ? theme : theme.name
const style = await getStyle(theme, themeName)
const { themeName, style } = await getStyle(theme)

const codeConfig = {
staticMediaQuery: config.staticMediaQuery,
2 changes: 1 addition & 1 deletion packages/mdx/src/smooth-code/splitter.tsx
Original file line number Diff line number Diff line change
@@ -269,7 +269,7 @@ export function splitTokens(
...extremes.map(e => e.end),
]

let oldTokens = tokens
let oldTokens = tokens || []
splitIndexes.forEach(splitIndex => {
const newTokens = [] as MergedLine["tokens"]
let i = 0
1 change: 1 addition & 0 deletions packages/mdx/src/utils/mixins.scss
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@
padding: 0;
border: none;
font-size: inherit;
text-align: inherit;

&:focus-visible {
outline-color: currentColor;
81 changes: 81 additions & 0 deletions packages/mdx/src/utils/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
Theme,
RawTheme,
getThemeColors,
getThemeColorsSync,
} from "@code-hike/lighter/dist/browser.esm.mjs"

const styleCache = new Map<string, string>()

export async function getStyle(theme: Theme) {
const themeName =
typeof theme === "string" ? theme : theme.name
if (styleCache.has(themeName)) {
return { themeName, style: styleCache.get(themeName) }
}
const rules = await getCSSVariables(theme)
const style = `[data-ch-theme="${themeName}"] \{ ${rules} \}`
styleCache.set(themeName, style)
return { themeName, style }
}

async function getCSSVariables(theme: Theme) {
const themeColors = await getThemeColors(theme)

if (!themeColors || typeof themeColors !== "object") {
throw new Error(
"[Code Hike error] Unknown theme format"
)
}
let rules = ""
for (const [first, value] of Object.entries(
themeColors
)) {
if (!value) {
continue
}
if (typeof value === "string") {
rules += `--ch-t-${first}: ${value};`
} else {
for (const [second, svalue] of Object.entries(
value
)) {
if (!svalue) {
continue
}
rules += `--ch-t-${first}-${second}: ${svalue};`
}
}
}
return rules
}

export function getCSSVariablesObjectSync(theme: RawTheme) {
const themeColors = getThemeColorsSync(theme)
const rules: Record<string, string> = {}
if (!themeColors || typeof themeColors !== "object") {
throw new Error(
"[Code Hike error] Unknown theme format"
)
}
for (const [first, value] of Object.entries(
themeColors
)) {
if (!value) {
continue
}
if (typeof value === "string") {
rules[`--ch-t-${first}`] = value
} else {
for (const [second, svalue] of Object.entries(
value
)) {
if (!svalue) {
continue
}
rules[`--ch-t-${first}-${second}`] = svalue
}
}
}
return rules
}
16,424 changes: 406 additions & 16,018 deletions yarn.lock

Large diffs are not rendered by default.