diff --git a/package.json b/package.json index b2e534a6..2b5ca0d0 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "workspaces": { "packages": [ "packages/mdx", + "packages/core", "playground", "site" ] diff --git a/packages/core/app/code.tsx b/packages/core/app/code.tsx new file mode 100644 index 00000000..d9211318 --- /dev/null +++ b/packages/core/app/code.tsx @@ -0,0 +1,12 @@ +"use client" + +import React from "react" +import { FlipCode } from "../src/flip-tokens" + +export function Code({ children, tokens }) { + return ( +
+ +
+ ) +} diff --git a/packages/core/app/hello.mdx b/packages/core/app/hello.mdx new file mode 100644 index 00000000..0a4554d1 --- /dev/null +++ b/packages/core/app/hello.mdx @@ -0,0 +1,40 @@ +import { Hike } from "./hike" +import { Scrollycoding } from "./scrollycoding" +import { Slideshow } from "./slideshow" +import { Code } from "./code" + +## This is an h2 from mdx + +And a paragraph + + + +hey + +```js +console.log(1) +// mark +console.log(2) +``` + +ho + +--- + +lets + +```js +console.log("hello 3") +``` + +go + +--- + +Bye + +```js +console.log("hello4") +``` + + diff --git a/packages/core/app/hike.tsx b/packages/core/app/hike.tsx new file mode 100644 index 00000000..de1639ae --- /dev/null +++ b/packages/core/app/hike.tsx @@ -0,0 +1,30 @@ +"use client" +import React from "react" + +export function Hike({ children, as, ...rest }) { + // console.log("client steps", children) + + const steps = React.Children.toArray(children).map( + (stepElement: any) => { + const slotElements = React.Children.toArray( + stepElement?.props?.children + ) + const step = {} + + slotElements.forEach((slotElement: any) => { + step[slotElement.props.className] = + slotElement.props.children + }) + + return step + } + ) + + // console.log("steps", steps) + + return React.createElement( + as, + { steps, ...rest }, + children + ) +} diff --git a/packages/core/app/layout.tsx b/packages/core/app/layout.tsx new file mode 100644 index 00000000..631f7910 --- /dev/null +++ b/packages/core/app/layout.tsx @@ -0,0 +1,33 @@ +import { Inter } from "next/font/google" + +import { Overpass } from "next/font/google" + +const overpass = Overpass({ + subsets: ["latin"], + variable: "--font-overpass", + display: "swap", +}) +const inter = Inter({ + subsets: ["latin"], + display: "swap", +}) + +export const metadata = { + title: "Next.js", + description: "Generated by Next.js", +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/packages/core/app/page.tsx b/packages/core/app/page.tsx new file mode 100644 index 00000000..f69c1752 --- /dev/null +++ b/packages/core/app/page.tsx @@ -0,0 +1,33 @@ +// app/page.tsx +import Link from "next/link" +import { allPosts, Post } from "contentlayer/generated" + +function PostCard(post: Post) { + return ( +
+

+ + {post.title} + +

+
+ ) +} + +export default function Home() { + const posts = allPosts + + return ( +
+

+ Next.js + Contentlayer Example +

+ {posts.map((post, idx) => ( + + ))} +
+ ) +} diff --git a/packages/core/app/playthrough.tsx b/packages/core/app/playthrough.tsx new file mode 100644 index 00000000..1c438a4c --- /dev/null +++ b/packages/core/app/playthrough.tsx @@ -0,0 +1,76 @@ +"use client" + +import React from "react" + +export function Playthrough({ steps }) { + const [index, setIndex] = React.useState(0) + const step = steps[index] + return ( +
+ +
+ {step.children} + { + e.preventDefault() + setIndex(index + 1) + }} + > + Next + +
+
+
Code
+
Preview
+
+
+ ) +} diff --git a/packages/core/app/posts/[slug]/page.tsx b/packages/core/app/posts/[slug]/page.tsx new file mode 100644 index 00000000..55657b12 --- /dev/null +++ b/packages/core/app/posts/[slug]/page.tsx @@ -0,0 +1,53 @@ +import { allPosts } from "contentlayer/generated" + +// import { Hike } from "../../hike" +// import { Scrollycoding } from "../../scrollycoding" +// import { Slideshow } from "../../slideshow" +// import { Code } from "../../code" +import { PostClient } from "./post.client" +import "./styles.css" + +export const generateStaticParams = async () => + allPosts.map(post => ({ slug: post._raw.flattenedPath })) + +export const generateMetadata = ({ + params, +}: { + params: { slug: string } +}) => { + const post = allPosts.find( + post => post._raw.flattenedPath === params.slug + ) + if (!post) + throw new Error( + `Post not found for slug: ${params.slug}` + ) + return { title: post.title } +} + +const PostLayout = ({ + params, +}: { + params: { slug: string } +}) => { + const post = allPosts.find( + post => post._raw.flattenedPath === params.slug + ) + if (!post) + throw new Error( + `Post not found for slug: ${params.slug}` + ) + + return ( +
+
+

{post.title}

+
+
+ +
+
+ ) +} + +export default PostLayout diff --git a/packages/core/app/posts/[slug]/post.client.tsx b/packages/core/app/posts/[slug]/post.client.tsx new file mode 100644 index 00000000..f7c51902 --- /dev/null +++ b/packages/core/app/posts/[slug]/post.client.tsx @@ -0,0 +1,7 @@ +"use client" +import { useMDXComponent } from "next-contentlayer/hooks" + +export function PostClient({ code }) { + const MDXContent = useMDXComponent(code) + return +} diff --git a/packages/core/app/posts/[slug]/styles.css b/packages/core/app/posts/[slug]/styles.css new file mode 100644 index 00000000..1cbcbb40 --- /dev/null +++ b/packages/core/app/posts/[slug]/styles.css @@ -0,0 +1,3 @@ +.mark { + background: #222299; +} diff --git a/packages/core/app/scrollycoding.tsx b/packages/core/app/scrollycoding.tsx new file mode 100644 index 00000000..1c67d0dc --- /dev/null +++ b/packages/core/app/scrollycoding.tsx @@ -0,0 +1,41 @@ +"use client" + +import React from "react" + +export function Scrollycoding({ steps }) { + // console.log("Scrollycoding", steps) + const [currentStep, setCurrentStep] = React.useState(0) + return ( +
+
+ {steps.map((step, i) => { + return ( +
setCurrentStep(i)} + > + {step.children} +
+ ) + })} +
+
+ {steps[currentStep].code} +
+
+ ) +} diff --git a/packages/core/app/slideshow.tsx b/packages/core/app/slideshow.tsx new file mode 100644 index 00000000..abd3ca3f --- /dev/null +++ b/packages/core/app/slideshow.tsx @@ -0,0 +1,46 @@ +"use client" + +import React from "react" + +export function Slideshow({ steps }) { + // console.log("Scrollycoding", steps) + const [currentStep, setCurrentStep] = React.useState(0) + const step = steps[currentStep] + return ( +
+
+ + + setCurrentStep(parseInt(e.target.value)) + } + /> + +
+
{step.children}
+
{step.code}
+
+ ) +} diff --git a/packages/core/contentlayer.config.ts b/packages/core/contentlayer.config.ts new file mode 100644 index 00000000..dd267d67 --- /dev/null +++ b/packages/core/contentlayer.config.ts @@ -0,0 +1,28 @@ +import { + defineDocumentType, + makeSource, +} from "contentlayer/source-files" +import { myPlugin } from "./src/remark/remark" + +export const Post = defineDocumentType(() => ({ + name: "Post", + filePathPattern: `**/*.mdx`, + contentType: "mdx", + fields: { + title: { type: "string", required: true }, + }, + computedFields: { + url: { + type: "string", + resolve: post => `/posts/${post._raw.flattenedPath}`, + }, + }, +})) + +export default makeSource({ + contentDirPath: "posts", + mdx: { + remarkPlugins: [myPlugin], + }, + documentTypes: [Post], +}) diff --git a/packages/core/mdx-components.tsx b/packages/core/mdx-components.tsx new file mode 100644 index 00000000..b41588c0 --- /dev/null +++ b/packages/core/mdx-components.tsx @@ -0,0 +1,17 @@ +import type { MDXComponents } from "mdx/types" + +// This file allows you to provide custom React components +// to be used in MDX files. You can import and use any +// React component you want, including components from +// other libraries. + +// This file is required to use MDX in `app` directory. +export function useMDXComponents( + components: MDXComponents +): MDXComponents { + return { + // Allows customizing built-in components, e.g. to add styling. + // h1: ({ children }) =>

{children}

, + ...components, + } +} diff --git a/packages/core/next-env.d.ts b/packages/core/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/packages/core/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/packages/core/next.config.js b/packages/core/next.config.js new file mode 100644 index 00000000..83d1a08c --- /dev/null +++ b/packages/core/next.config.js @@ -0,0 +1,10 @@ +// using js instead of mjs because of https://github.com/contentlayerdev/contentlayer/issues/272#issuecomment-1237021441 +const { withContentlayer } = require("next-contentlayer") + +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + swcMinify: true, +} + +module.exports = withContentlayer(nextConfig) diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..f0abd39f --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,27 @@ +{ + "name": "@code-hike/core", + "version": "0.9.0", + "scripts": { + "dev": "next dev", + "test": "vitest" + }, + "devDependencies": { + "@mdx-js/loader": "^2.3.0", + "@mdx-js/react": "^2.3.0", + "@next/mdx": "^13.4.19", + "@types/mdx": "^2.0.6", + "@vitejs/plugin-react": "^4.0.4", + "contentlayer": "^0.3.4", + "jsdom": "^22.1.0", + "next": "^13.4.19", + "next-contentlayer": "^0.3.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "unist-util-visit": "^5.0.0", + "vitest": "^0.34.3" + }, + "dependencies": { + "@code-hike/lighter": "^0.8.2", + "diff": "^5.1.0" + } +} diff --git a/packages/core/posts/post-01.mdx b/packages/core/posts/post-01.mdx new file mode 100644 index 00000000..71beed0f --- /dev/null +++ b/packages/core/posts/post-01.mdx @@ -0,0 +1,45 @@ +--- +title: My First Post +--- + +import { Hike } from "../app/hike" +import { Scrollycoding } from "../app/scrollycoding" +import { Slideshow } from "../app/slideshow" +import { Code } from "../app/code" + +## This is an h2 from absss + +And a paragraphs + + + +hey + +```js +console.log(1) + +// mark +console.log(2) +``` + +ho + +--- + +lets + +```js +console.log(2, 0) +``` + +go + +--- + +Bye + +```js +console.log("hello4") +``` + + diff --git a/packages/core/posts/post-03.mdx b/packages/core/posts/post-03.mdx new file mode 100644 index 00000000..bbcaec4f --- /dev/null +++ b/packages/core/posts/post-03.mdx @@ -0,0 +1,11 @@ +--- +title: Post 3 +--- + +import { Test } from "./test" + +## This is an h2 from mdx + +And a paragraph + + diff --git a/packages/core/posts/svelte.mdx b/packages/core/posts/svelte.mdx new file mode 100644 index 00000000..6a233366 --- /dev/null +++ b/packages/core/posts/svelte.mdx @@ -0,0 +1,74 @@ +--- +title: Learn Svelte +--- + +import { Hike } from "../app/hike" +import { Playthrough } from "../app/playthrough" +import { Code } from "../app/code" + + + +Welcome to the Svelte tutorial! This will teach you everything you need to know to easily build web applications of all sizes, with high performance and a small footprint. + +You can also consult the [API docs](https://svelte.dev/docs) and the [examples](https://svelte.dev/examples), or — if you're impatient to start hacking on your machine locally — create a project with `npm init svelte`. + +## What is Svelte? + +Svelte is a tool for building web applications. Like other user interface frameworks, it allows you to build your app _declaratively_ out of components that combine markup, styles and behaviours. + +These components are _compiled_ into small, efficient JavaScript modules that eliminate overhead traditionally associated with UI frameworks. + +You can build your entire app with Svelte (for example, using an application framework like [SvelteKit](https://kit.svelte.dev/), which this tutorial will cover), or you can add it incrementally to an existing codebase. You can also ship components as standalone packages that work anywhere. + +## How to use this tutorial + +> You'll need to have basic familiarity with HTML, CSS and JavaScript to understand Svelte. + +This tutorial is split into four main parts: + +- [Basic Svelte](https://learn.svelte.dev/tutorial/welcome-to-svelte) (you are here) +- [Advanced Svelte](https://learn.svelte.dev/tutorial/tweens) +- [Basic SvelteKit](https://learn.svelte.dev/tutorial/introducing-sveltekit) +- [Advanced SvelteKit](https://learn.svelte.dev/tutorial/optional-params) + +Each section will present an exercise designed to illustrate a feature. Later exercises build on the knowledge gained in earlier ones, so it's recommended that you go from start to finish. If necessary, you can navigate via the menu above. + +```svelte src/App.svelte +

Welcome!

+``` + +If you get stuck, you can click the `solve` button to the left of the editorin the top right of the editor view. (Use the toggle below to switch between tutorial and editor views. The `solve` button is disabled on sections like this one that don't include an exercise.) Try not to rely on it too much; you will learn faster by figuring out where to put each suggested code block and manually typing it in to the editor. + +--- + +## Part 1 / Introduction / Your first component + +In Svelte, an application is composed from one or more _components_. A component is a reusable self-contained block of code that encapsulates HTML, CSS and JavaScript that belong together, written into a `.svelte` file. The `App.svelte` file, open in the code editor to the right, is a simple component. + +## Adding data + +A component that just renders some static markup isn't very interesting. Let's add some data. + +First, add a script tag to your component and declare a `name` variable: + +```svelte App.svelte + + +

Hello world!

+``` + +Then, we can refer to `name` in the markup: + +```svelte App.svelte +

Hello {name}!

+``` + +Inside the curly braces, we can put any JavaScript we want. Try changing `name` to `name.toUpperCase()` for a shoutier greeting. + +```svelte App.svelte +

Hello {name.toUpperCase()}!

+``` + +
diff --git a/packages/core/posts/test.client.tsx b/packages/core/posts/test.client.tsx new file mode 100644 index 00000000..c0dc18cf --- /dev/null +++ b/packages/core/posts/test.client.tsx @@ -0,0 +1,14 @@ +"use client" + +import React from "react" + +export function TestClient() { + const [x, setX] = React.useState(0) + return ( +
+
Test Client
+
{x}
+ +
+ ) +} diff --git a/packages/core/posts/test.tsx b/packages/core/posts/test.tsx new file mode 100644 index 00000000..47511247 --- /dev/null +++ b/packages/core/posts/test.tsx @@ -0,0 +1,11 @@ +"use client" + +import { TestClient } from "./test.client" + +export function Test() { + return ( +
+ Test server +
+ ) +} diff --git a/packages/core/src/differ.test.ts b/packages/core/src/differ.test.ts new file mode 100644 index 00000000..7248bd71 --- /dev/null +++ b/packages/core/src/differ.test.ts @@ -0,0 +1,45 @@ +import { test } from "vitest" +import { diff } from "./differ" +import { preload } from "@code-hike/lighter" +import { tokenize } from "./tokens/code-to-tokens" + +test.only("differ withIds", async () => { + const tokens = await tokenize( + " a b c ", + "javascript", + "github-dark" + ) + const result = diff(null, tokens) + + console.log(result) +}) + +test("differ", async () => { + const result = await getTokens("a b c d", "a c f d g") + console.log(result) +}) + +test("differ deleted id", async () => { + const result = await getTokens("a b c", "a d c") + console.log(result) +}) + +async function getTokens(prev, next) { + await preload(["javascript"], "github-dark") + + const prevTokens = await tokenize( + prev, + "javascript", + "github-dark" + ) + const nextTokens = await tokenize( + next, + "javascript", + "github-dark" + ) + + const result = diff(diff(null, prevTokens), nextTokens) + return result +} + +// can a new token get the id of a removed one, duplicate ids? diff --git a/packages/core/src/differ.ts b/packages/core/src/differ.ts new file mode 100644 index 00000000..e9a8b878 --- /dev/null +++ b/packages/core/src/differ.ts @@ -0,0 +1,156 @@ +import { diffArrays } from "diff" +import { Token } from "@code-hike/lighter" +import { + TokenOrGroup, + TokenWithId, + TokenWithIdOrGroup, + WhitespaceToken, +} from "./types" + +export function withIds( + tokens: TokenOrGroup[], + start: number = 0 +) { + let id = start + return tokens.map(tokenOrGroup => { + if ("tokens" in tokenOrGroup) { + const groupTokens = withIds(tokenOrGroup.tokens, id) + id += groupTokens.length + return { + ...tokenOrGroup, + tokens: groupTokens, + } + } else if (!tokenOrGroup.style) { + return tokenOrGroup as WhitespaceToken + } + return { + ...tokenOrGroup, + id: id++, + } + }) +} + +export function diff( + prev: TokenWithIdOrGroup[], + next: TokenOrGroup[] +): TokenWithIdOrGroup[] { + console.log({ next }) + if (!prev) { + return withIds(next) + } + + const ps = toOnlyTokens(prev as any) + const ns = toOnlyTokens(next as any) + + const result = diffArrays(ps, ns, { + comparator: (a, b) => a.content == b.content, + }) + + // highest id in ps + let highestId = 0 + ps.forEach(t => { + if (t.id > highestId) { + highestId = t.id + } + }) + + const tokensWithId: TokenWithId[] = [] + let nId = 0 + let pId = 0 + result.forEach(part => { + const { added, removed, count, value } = part + if (added) { + for (let i = 0; i < count; i++) { + tokensWithId.push({ + ...ns[nId++], + id: ++highestId, + }) + } + } else if (removed) { + value.forEach((t: TokenWithId) => { + pId++ + tokensWithId.push({ + ...t, + style: { + ...t.style, + position: "absolute", + opacity: 0, + }, + deleted: true, + }) + }) + } else { + value.forEach(_ => { + tokensWithId.push({ + ...ns[nId++], + id: ps[pId].id, + }) + pId++ + }) + } + }) + + const tokens = replaceTokens(next, tokensWithId) + + return tokens +} + +function replaceTokens( + tokens: TokenOrGroup[], + tokensWithId: TokenWithId[] +): TokenWithIdOrGroup[] { + const result: TokenWithIdOrGroup[] = [] + + tokens.forEach(tokenOrGroup => { + if ("tokens" in tokenOrGroup) { + result.push({ + ...tokenOrGroup, + tokens: replaceTokens( + tokenOrGroup.tokens, + tokensWithId + ), + }) + } else if (!tokenOrGroup.style) { + result.push(tokenOrGroup as WhitespaceToken) + } else { + while ( + tokensWithId[0]?.deleted || + (tokensWithId[0] && !tokensWithId[0]?.style) + ) { + result.push(tokensWithId.shift()) + } + + result.push(tokensWithId.shift()) + + while ( + tokensWithId[0]?.deleted || + (tokensWithId[0] && !tokensWithId[0]?.style) + ) { + result.push(tokensWithId.shift()) + } + } + }) + + return result +} + +// flat annotations and ignore whitespace +function toOnlyTokens( + tokens: ({ tokens: T[] } | T)[] +): T[] { + return (tokens as TokenOrGroup[]).flatMap( + tokenOrGroup => { + if ("tokens" in tokenOrGroup) { + return toOnlyTokens(tokenOrGroup.tokens) + } + if ( + !tokenOrGroup.style || + // was deleted + (tokenOrGroup as any).style?.position === "absolute" + ) { + return [] + } + return [tokenOrGroup] + } + ) as T[] +} diff --git a/packages/core/src/flip-animate.js b/packages/core/src/flip-animate.js new file mode 100644 index 00000000..43d8db7e --- /dev/null +++ b/packages/core/src/flip-animate.js @@ -0,0 +1,208 @@ +const config = { + removeDuration: 100, + moveDuration: 300, + addDuration: 300, +} + +export function animate( + elements, + firstSnapshot, + lastSnapshot +) { + const groups = { + removed: [], + moved: [], + forwards: [], + backwards: [], + added: [], + } + + let previousKind = null + elements.forEach(el => { + const id = el.getAttribute("ch-x") + const first = firstSnapshot[id] + const last = lastSnapshot[id] + + const kind = classify(first, last) + if (!kind) { + // untouched + previousKind = null + return + } + + // todo don't group "moves" when translate is different + if (previousKind !== kind) { + previousKind = kind + groups[kind].push([]) + } + + groups[kind][groups[kind].length - 1].push(el) + }) + + // sort moved, first bwd moves, then fwd moves (inverted) + groups.forwards.reverse() + groups.moved = [...groups.backwards, ...groups.forwards] + + const removeDuration = fullStaggerDuration( + groups.removed.length, + config.removeDuration + ) + const moveDuration = fullStaggerDuration( + groups.moved.length, + config.moveDuration + ) + const addDuration = fullStaggerDuration( + groups.added.length, + config.addDuration + ) + + groups.removed.forEach((group, groupIndex) => { + group.forEach(el => { + const id = el.getAttribute("ch-x") + const first = firstSnapshot[id] + const last = lastSnapshot[id] + const delay = staggerDelay( + groupIndex, + groups.removed.length, + removeDuration, + config.removeDuration + ) + animateRemove(el, first, last, delay) + }) + }) + + // todo group by backwards and forwards + groups.moved.forEach((group, groupIndex) => { + group.forEach(el => { + const id = el.getAttribute("ch-x") + const first = firstSnapshot[id] + const last = lastSnapshot[id] + const delay = + removeDuration + + staggerDelay( + groupIndex, + groups.moved.length, + moveDuration, + config.moveDuration + ) + + animateMove(el, first, last, delay) + }) + }) + + groups.added.forEach((group, groupIndex) => { + group.forEach(el => { + const id = el.getAttribute("ch-x") + const first = firstSnapshot[id] + const last = lastSnapshot[id] + const delay = + removeDuration + + moveDuration + + staggerDelay( + groupIndex, + groups.added.length, + addDuration, + config.addDuration + ) + + animateAdd(el, first, last, delay) + }) + }) +} + +function animateRemove(element, first, last, delay) { + const dx = first.x - last.x + const dy = first.y - last.y + element.animate( + { + opacity: [1, 0], + transform: [ + `translate(${dx}px, ${dy}px)`, + `translate(${dx}px, ${dy}px)`, + ], + }, + { + // todo maybe use removeDuration from fullStaggerDuration + duration: config.removeDuration, + easing: "ease-out", + fill: "both", + delay, + } + ) +} + +function animateMove(element, first, last, delay) { + const dx = first.x - last.x + const dy = first.y - last.y + element.animate( + { + opacity: [first.opacity, last.opacity], + transform: [ + `translate(${dx}px, ${dy}px)`, + "translate(0, 0)", + ], + color: [first.color, last.color], + }, + { + duration: config.moveDuration, + easing: "ease-in-out", + fill: "both", + delay, + } + ) +} + +function animateAdd(element, first, last, delay) { + element.animate( + { opacity: [0, 1] }, + { + duration: config.addDuration, + fill: "both", + easing: "ease-out", + delay, + } + ) +} + +function classify(first, last) { + if ( + first && + first.x === last.x && + first.y === last.y && + first.opacity === last.opacity + // todo add color? + ) { + // unchanged + return null + } + + // todo shouldn't use opacity for this + if (last.opacity < 0.5) { + return "removed" + } + + // todo shouldn't use opacity for this + if (!first || first.opacity < 0.5) { + return "added" + } + + const dx = first.x - last.x + const dy = first.y - last.y + + const bwd = dy > 0 || (dy == 0 && dx > 0) + + return bwd ? "backwards" : "forwards" +} + +function fullStaggerDuration(count, singleDuration) { + if (count === 0) return 0 + return 2 * singleDuration * (1 - 1 / (1 + count)) + // return 1.5 * singleDuration - 1 / (1 + count) +} + +function staggerDelay(i, n, duration, singleDuration) { + if (i === 0) return 0 + const max = duration - singleDuration + + return (i / (n - 1)) * max +} diff --git a/packages/core/src/flip-tokens.tsx b/packages/core/src/flip-tokens.tsx new file mode 100644 index 00000000..c3a9b865 --- /dev/null +++ b/packages/core/src/flip-tokens.tsx @@ -0,0 +1,78 @@ +"use-client" + +import React from "react" +import { diff } from "./differ" +import { Flip } from "./flip" +import { TokenOrGroup, TokenWithIdOrGroup } from "./types" + +export function FlipCode({ + tokens, +}: { + tokens: TokenOrGroup[] +}) { + const tokensWithIds = useTokensWithIds(tokens) + return +} + +function useTokensWithIds( + tokens: TokenOrGroup[] +): TokenWithIdOrGroup[] { + const prevRef = React.useRef() + const result = React.useMemo( + () => diff(prevRef.current, tokens), + [tokens] + ) + + React.useEffect(() => { + prevRef.current = result + }, [result]) + + return result +} + +function Tokens({ + tokens, +}: { + tokens: TokenWithIdOrGroup[] +}) { + return ( + + {tokens.map((token, i) => ( + + ))} + + ) +} + +function TokenOrGroup({ + token, +}: { + token: TokenWithIdOrGroup +}) { + if ("tokens" in token) { + return ( + + {token.tokens.map((token, i) => ( + + ))} + + ) + } + + return "id" in token ? ( + + {token.content} + + ) : ( + // whitespace: + <>{token.content} + ) +} diff --git a/packages/core/src/flip.jsx b/packages/core/src/flip.jsx new file mode 100644 index 00000000..7116bec9 --- /dev/null +++ b/packages/core/src/flip.jsx @@ -0,0 +1,65 @@ +"use-client" + +import React from "react" +import { animate } from "./flip-animate" + +export class Flip extends React.Component { + constructor(props) { + super(props) + this.ref = React.createRef() + } + + getSnapshotBeforeUpdate(prevProps, prevState) { + const snapshot = getSnapshot(this.ref.current) + return snapshot + } + + componentDidUpdate(prevProps, prevState, firstSnapshot) { + const parent = this.ref.current + const elements = parent.querySelectorAll("[ch-x]") + + // stop all animations + elements.forEach(el => { + el.getAnimations().forEach(a => { + a.cancel() + }) + }) + + const lastSnapshot = getSnapshot(parent) + animate(elements, firstSnapshot, lastSnapshot) + } + + render() { + return ( +
+        {this.props.children}
+      
+ ) + } +} + +function getSnapshot(parent) { + const snapshot = {} + parent.querySelectorAll("[ch-x]").forEach(el => { + const id = el.getAttribute("ch-x") + const { x, y } = el.getBoundingClientRect() + const style = window.getComputedStyle(el) + const opacity = Number(style.opacity) ?? 1 + const color = style.color + + snapshot[id] = { x, y, opacity, color } + }) + return snapshot +} diff --git a/packages/core/src/remark/remark.ts b/packages/core/src/remark/remark.ts new file mode 100644 index 00000000..156ee6d2 --- /dev/null +++ b/packages/core/src/remark/remark.ts @@ -0,0 +1,109 @@ +import { preload } from "@code-hike/lighter" +import { visit } from "unist-util-visit" +import { toValueExpression } from "./to-estree" +import { visitAsync } from "./visit.js" +import { tokenize } from "../tokens/code-to-tokens" + +const theme = "github-dark" + +async function preloadLanguages(tree) { + const langs = new Set() + + visit(tree, "code", node => { + const { lang } = node + if (lang) { + langs.add(lang) + } + }) + + await preload([...langs], theme) +} + +export const myPlugin = config => { + return async (tree, file) => { + await preloadLanguages(tree) + + await visitAsync(tree, "code", async node => { + const { lang, meta, value } = node + const tokens = await tokenize(value, lang, theme) + + node.type = "mdxJsxFlowElement" + node.name = "Code" + node.attributes = [ + { + type: "mdxJsxAttribute", + name: "blaze", + value: "code", + }, + { + type: "mdxJsxAttribute", + name: "lang", + value: lang, + }, + { + type: "mdxJsxAttribute", + name: "meta", + value: meta, + }, + { + type: "mdxJsxAttribute", + name: "tokens", + value: toValueExpression(tokens), + }, + ] + }) + + visit(tree, "mdxJsxFlowElement", node => { + if (node.name === "Hike") { + processSteps(node) + } + }) + return tree + } +} + +function processSteps(node) { + const steps = [{}] + + node.children.forEach(child => { + if (child?.type === "thematicBreak") { + steps.push({}) + return + } + + const step = steps[steps.length - 1] + // console.log(child) + const blazeAttribute = child?.attributes?.find( + attribute => attribute.name === "blaze" + ) + + const slot = blazeAttribute?.value ?? "children" + + step[slot] = step[slot] ?? [] + step[slot].push(child) + + // TODO push a placeholder to slot["children"] for the static version + }) + + node.children = steps.map(step => { + const slots = Object.keys(step) + + return { + type: "mdxJsxFlowElement", + name: "step", + attributes: {}, + children: slots.map(slotName => ({ + type: "mdxJsxFlowElement", + name: "slot", + attributes: [ + { + type: "mdxJsxAttribute", + name: "className", + value: slotName, + }, + ], + children: step[slotName], + })), + } + }) +} diff --git a/packages/core/src/remark/to-estree.ts b/packages/core/src/remark/to-estree.ts new file mode 100644 index 00000000..f5f7f22b --- /dev/null +++ b/packages/core/src/remark/to-estree.ts @@ -0,0 +1,210 @@ +/** + * Convert a value to an ESTree node + * + * @param value - The value to convert + * @param options - Additional options to configure the output. + * @returns The ESTree node. + */ +export function valueToEstree(value, options?) { + if (value === undefined) { + return { type: "Identifier", name: "undefined" } + } + if (value == null) { + return { type: "Literal", value: null, raw: "null" } + } + if (value === Number.POSITIVE_INFINITY) { + return { type: "Identifier", name: "Infinity" } + } + if (Number.isNaN(value)) { + return { type: "Identifier", name: "NaN" } + } + if (typeof value === "boolean") { + return { type: "Literal", value, raw: String(value) } + } + if (typeof value === "bigint") { + return value >= 0 + ? { + type: "Literal", + value, + raw: `${value}n`, + bigint: String(value), + } + : { + type: "UnaryExpression", + operator: "-", + prefix: true, + argument: valueToEstree(-value, options), + } + } + if (typeof value === "number") { + return value >= 0 + ? { type: "Literal", value, raw: String(value) } + : { + type: "UnaryExpression", + operator: "-", + prefix: true, + argument: valueToEstree(-value, options), + } + } + if (typeof value === "string") { + return { + type: "Literal", + value, + raw: JSON.stringify(value), + } + } + if (typeof value === "symbol") { + if ( + value.description && + value === Symbol.for(value.description) + ) { + return { + type: "CallExpression", + optional: false, + callee: { + type: "MemberExpression", + computed: false, + optional: false, + object: { type: "Identifier", name: "Symbol" }, + property: { type: "Identifier", name: "for" }, + }, + arguments: [ + valueToEstree(value.description, options), + ], + } + } + throw new TypeError( + `Only global symbols are supported, got: ${String( + value + )}` + ) + } + if (Array.isArray(value)) { + const elements = [] + for (let i = 0; i < value.length; i += 1) { + elements.push( + i in value ? valueToEstree(value[i], options) : null + ) + } + return { type: "ArrayExpression", elements } + } + if (value instanceof RegExp) { + return { + type: "Literal", + value, + raw: String(value), + regex: { pattern: value.source, flags: value.flags }, + } + } + if (value instanceof Date) { + return { + type: "NewExpression", + callee: { type: "Identifier", name: "Date" }, + arguments: [valueToEstree(value.getTime(), options)], + } + } + if (value instanceof Map) { + return { + type: "NewExpression", + callee: { type: "Identifier", name: "Map" }, + arguments: [ + valueToEstree([...value.entries()], options), + ], + } + } + if ( + // https://github.com/code-hike/codehike/issues/194 + // value instanceof BigInt64Array || + // value instanceof BigUint64Array || + value instanceof Float32Array || + value instanceof Float64Array || + value instanceof Int8Array || + value instanceof Int16Array || + value instanceof Int32Array || + value instanceof Set || + value instanceof Uint8Array || + value instanceof Uint8ClampedArray || + value instanceof Uint16Array || + value instanceof Uint32Array + ) { + return { + type: "NewExpression", + callee: { + type: "Identifier", + name: value.constructor.name, + }, + arguments: [valueToEstree([...value], options)], + } + } + if ( + value instanceof URL || + value instanceof URLSearchParams + ) { + return { + type: "NewExpression", + callee: { + type: "Identifier", + name: value.constructor.name, + }, + arguments: [valueToEstree(String(value), options)], + } + } + if (options?.instanceAsObject || isPlainObject(value)) { + // if ((value as any)?.name === MDX_CHILDREN) { + // const tree = { ...(value as any) } + // tree.name = null + // return (mdastToEstree(tree) as any).body[0].expression + // } + + if (value?.type === "mdxJsxAttributeValueExpression") { + return value.data.estree.body[0].expression + } + + return { + type: "ObjectExpression", + properties: Object.entries(value).map( + ([name, val]) => ({ + type: "Property", + method: false, + shorthand: false, + computed: false, + kind: "init", + key: valueToEstree(name, options), + value: valueToEstree(val, options), + }) + ), + } + } + + throw new TypeError(`Unsupported value: ${String(value)}`) +} + +function isPlainObject(x) { + var toString = Object.prototype.toString + var prototype + return ( + toString.call(x) === "[object Object]" && + ((prototype = Object.getPrototypeOf(x)), + prototype === null || + prototype === Object.getPrototypeOf({})) + ) +} + +export function toValueExpression(value) { + return { + type: "mdxJsxAttributeValueExpression", + value: JSON.stringify(value), + data: { + estree: { + type: "Program", + body: [ + { + type: "ExpressionStatement", + expression: valueToEstree(value), + }, + ], + sourceType: "module", + }, + }, + } +} diff --git a/packages/core/src/remark/visit.ts b/packages/core/src/remark/visit.ts new file mode 100644 index 00000000..7787dd59 --- /dev/null +++ b/packages/core/src/remark/visit.ts @@ -0,0 +1,9 @@ +import { visit } from "unist-util-visit" + +export async function visitAsync(tree, test, visitor) { + const promises = [] + visit(tree, test, node => { + promises.push(visitor(node)) + }) + await Promise.all(promises) +} diff --git a/packages/core/src/tokens/code-to-tokens.ts b/packages/core/src/tokens/code-to-tokens.ts new file mode 100644 index 00000000..14254e13 --- /dev/null +++ b/packages/core/src/tokens/code-to-tokens.ts @@ -0,0 +1,137 @@ +import { + highlight, + extractAnnotations, + Theme, + Lines, + Tokens, +} from "@code-hike/lighter" +import { TokenOrGroup } from "../types" + +const annotationNames = ["mark"] + +export type TokenItem = TokenOrGroup +export type TokenList = TokenOrGroup[] + +function joinTokens(tokens: Tokens): TokenOrGroup[] { + return tokens.map(tokenOrGroup => { + if ("tokens" in tokenOrGroup) { + return { + name: tokenOrGroup.annotationName, + query: tokenOrGroup.annotationQuery, + inline: true, + tokens: joinTokens(tokenOrGroup.tokens), + } + } else { + return tokenOrGroup + } + }) +} + +function joinLines(lines: Lines): TokenOrGroup[] { + const joinedTokens: TokenOrGroup[] = [] + lines.forEach(lineOrGroup => { + if ("lines" in lineOrGroup) { + joinedTokens.push({ + name: lineOrGroup.annotationName, + query: lineOrGroup.annotationQuery, + inline: false, + tokens: joinLines(lineOrGroup.lines), + }) + } else { + const tokens = joinTokens(lineOrGroup.tokens) + joinedTokens.push(...tokens) + joinedTokens.push({ content: "\n", style: undefined }) + } + }) + return joinedTokens +} + +function splitWhitespace(tokens: TokenOrGroup[]) { + const ejected: TokenOrGroup[] = [] + tokens.forEach(tokenOrGroup => { + if ("tokens" in tokenOrGroup) { + ejected.push({ + ...tokenOrGroup, + tokens: splitWhitespace(tokenOrGroup.tokens), + }) + } else { + const [before, content, after] = + splitSurroundingWhitespace(tokenOrGroup.content) + if (before?.length) { + ejected.push({ content: before, style: undefined }) + } + if (content.length) { + ejected.push({ content, style: tokenOrGroup.style }) + } + if (after?.length) { + ejected.push({ content: after, style: undefined }) + } + } + }) + return ejected +} + +function joinWhitespace(tokens: TokenOrGroup[]) { + const joinedTokens: TokenOrGroup[] = [] + tokens.forEach(tokenOrGroup => { + if ("tokens" in tokenOrGroup) { + joinedTokens.push({ + ...tokenOrGroup, + tokens: joinWhitespace(tokenOrGroup.tokens), + }) + } else { + const last = joinedTokens[joinedTokens.length - 1] + if ( + last && + "style" in last && + !last.style && + !tokenOrGroup.style + ) { + last.content += tokenOrGroup.content + } else if (tokenOrGroup.content === "") { + // ignore empty tokens + } else { + joinedTokens.push(tokenOrGroup) + } + } + }) + return joinedTokens +} + +export async function tokenize( + code: string, + lang: string, + theme: Theme +) { + const { code: codeWithoutAnnotations, annotations } = + await extractAnnotations(code, lang, annotationNames) + + const { lines } = await highlight( + codeWithoutAnnotations, + lang, + theme, + { + scopes: true, + annotations, + } + ) + const tokens = joinLines(lines) + // split surrounding whitespace for each token + const splitTokens = splitWhitespace(tokens) + + // join consecutive whitespace tokens + const joinedTokens = joinWhitespace(splitTokens) + + return joinedTokens +} + +// splits " \t foo bar \n" into [" \t ","foo bar"," \n"] +// "foo bar" -> ["","foo bar",""] +function splitSurroundingWhitespace(content: string) { + const trimmed = content.trim() + const before = content.slice(0, content.indexOf(trimmed)) + const after = content.slice( + content.indexOf(trimmed) + trimmed.length + ) + return [before, trimmed, after] +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 00000000..f9f92e12 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,37 @@ +import { Lines, Token, Tokens } from "@code-hike/lighter" + +// highlight.js transforms Lines to TokenOrGroup[] + +export type TokenGroup = { + name: string + query?: string + inline: boolean + tokens: TokenOrGroup[] +} +export type TokenOrGroup = Token | TokenGroup + +// differ.js transforms TokenOrGroup[] to TokenWithId[] + +export type TokenWithId = { + id: number + content: string + style: React.CSSProperties + deleted?: boolean +} + +export type TokenWithIdOrGroup = + | TokenWithId + | TokenWithIdGroup + | WhitespaceToken + +export type WhitespaceToken = { + content: string + style: undefined +} + +export type TokenWithIdGroup = { + name: string + query?: string + inline: boolean + tokens: TokenWithIdOrGroup[] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 00000000..0327725f --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "target": "ESNext", + "paths": { + "contentlayer/generated": [ + "./.contentlayer/generated" + ] + }, + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + "**/*.ts", + "**/*.tsx", + ".contentlayer/generated" + ], + "exclude": ["node_modules"] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 00000000..6618448c --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config" +// import react from "@vitejs/plugin-react" + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [], + test: { + environment: "jsdom", + }, +}) diff --git a/packages/mdx/pages/bug.js b/packages/mdx/pages/bug.js new file mode 100644 index 00000000..0cd8321e --- /dev/null +++ b/packages/mdx/pages/bug.js @@ -0,0 +1,94 @@ +import { Code } from "../src/smooth-tokens" +import { tokenizeSync } from "../src/differ" +import { + getThemeColorsSync, + preload, +} from "@code-hike/lighter" +import React from "react" + +export default function Page() { + const [ready, setReady] = React.useState(false) + + React.useEffect(() => { + preload(["jsx"], "github-dark").then(() => { + setReady(true) + }) + }, []) + + if (!ready) { + return ( +
+ ) + } + + return
+} + +function Main() { + const [theme, setTheme] = React.useState("github-dark") + const [fromText, setFromText] = React.useState(code[0]) + const [toText, setToText] = React.useState(code[1]) + const [fromLangLoaded, setFromLangLoaded] = + React.useState("jsx") + const [toLangLoaded, setToLangLoaded] = + React.useState("jsx") + const [right, setRight] = React.useState(false) + + const tokens = React.useMemo(() => { + return right + ? tokenizeSync(toText, toLangLoaded, theme) + : tokenizeSync(fromText, fromLangLoaded, theme) + }, [ + right, + toText, + toLangLoaded, + fromText, + fromLangLoaded, + theme, + ]) + + return ( +
+ + +
+ ) +} + +// prettier-ignore +const code = [` +foo +foo +foo +foo bar +`.trim(),` +foo +foo +foo +foo + bar +`.trim() +] diff --git a/packages/mdx/pages/css-animations.js b/packages/mdx/pages/css-animations.js new file mode 100644 index 00000000..806e5dae --- /dev/null +++ b/packages/mdx/pages/css-animations.js @@ -0,0 +1,63 @@ +import React from "react" +import { setLines } from "../src/lines" + +const a = [ + { content: "A1", id: "1" }, + { content: "A2", id: "2" }, + { content: "A3", id: "3" }, + { content: "A4", id: "4" }, + { content: "A5", id: "5" }, +] + +const b = [ + { content: "A1", id: "1" }, + { content: "B1", id: "1.5" }, + { content: "A2", id: "2" }, + { content: "A3", id: "3" }, + { content: "A5", id: "5" }, +] + +const c = [ + { content: "B1", id: "1.5" }, + { content: "C1", id: "1.6" }, + { content: "A3", id: "3" }, +] + +export default function Home() { + return +} + +function Code({ lines }) { + const ref = React.useRef() + console.log("render") + return ( +
+ +
+ {lines.map((line, i) => { + return ( +
+ {line.content} +
+ ) + })} +
+
+ ) +} diff --git a/packages/mdx/pages/flip.js b/packages/mdx/pages/flip.js new file mode 100644 index 00000000..764f95c6 --- /dev/null +++ b/packages/mdx/pages/flip.js @@ -0,0 +1,135 @@ +import React from "react" + +export default function Page() { + const [x, setX] = React.useState(100) + + return ( +
+ + +
+ ) +} + +function Flip({ x }) { + const flipRef = React.useRef({ first: null, last: null }) + const divRef = React.useRef() + const [w, setW] = React.useState(x) + + React.useLayoutEffect(() => { + if (w !== x) { + flipRef.current.first = getRect(divRef) + console.log("first", flipRef.current.first) + setW(x) + } else if (flipRef.current.first) { + flipRef.current.last = getRect(divRef) + console.log("last", flipRef.current.last) + invert(divRef, flipRef) + } else { + console.log("first render, no prev") + } + }) + + return ( +
+ +
+ ) +} + +function getRect(divRef) { + const div = divRef.current + const rect = div + .querySelector("[ch-k='1']") + .getBoundingClientRect() + const { x, y } = rect + return { x, y } +} + +function invert(divRef, flipRef) { + console.log("invert") + const el = divRef.current.querySelector("[ch-k='1']") + const dx = + flipRef.current.first.x - flipRef.current.last.x + const dy = + flipRef.current.first.y - flipRef.current.last.y + + el.animate( + { + transform: [ + `translate(${dx}px, ${dy}px)`, + "translate(0, 0)", + ], + }, + { + duration: 500, + easing: "ease-in-out", + fill: "both", + } + ) +} + +const Marginal = React.memo(({ left }) => { + console.log("Marginal", left) + return ( +
+
+
+ hey +
+
+ ) +}) + +class Flip2 extends React.Component { + constructor(props) { + super(props) + this.divRef = React.createRef() + this.flipRef = React.createRef() + } + + getSnapshotBeforeUpdate(prevProps, prevState) { + if (this.divRef.current) { + return getRect(this.divRef) + } + return null + } + + componentDidUpdate(prevProps, prevState, snapshot) { + if (snapshot) { + const rect = getRect(this.divRef) + const dx = snapshot.x - rect.x + const dy = snapshot.y - rect.y + const el = + this.divRef.current.querySelector("[ch-k='1']") + el.animate( + { + transform: [ + `translate(${dx}px, ${dy}px)`, + "translate(0, 0)", + ], + }, + { + duration: 500, + easing: "ease-in-out", + fill: "both", + } + ) + } + } + + render() { + const { x } = this.props + return ( +
+ +
+ ) + } +} diff --git a/packages/mdx/pages/styles.css b/packages/mdx/pages/styles.css index 2d298a81..1d414a07 100644 --- a/packages/mdx/pages/styles.css +++ b/packages/mdx/pages/styles.css @@ -112,3 +112,31 @@ div#__next > div { --ch-25: #afb8c133; --ch-26: #ffffffe6; } + +.code { + background-color: #222; + padding: 20px; + font-size: 15px; + color: #fff; + width: 600px; + height: 400px; + margin: 0 auto; + padding-left: 300px; + font-family: monospace; +} + +.line { + outline: 1px dashed #9999; + width: 200px; + animation: x var(--duration) ease-in-out; + animation-fill-mode: backwards; +} + +@keyframes x { + 0% { + transform: var(--transform); + opacity: var(--opacity); + } + 100% { + } +} diff --git a/packages/mdx/pages/tokens.js b/packages/mdx/pages/tokens.js new file mode 100644 index 00000000..c5fdf493 --- /dev/null +++ b/packages/mdx/pages/tokens.js @@ -0,0 +1,210 @@ +import { FlipCode } from "../src/flip-tokens" +import { tokenize, tokenizeSync } from "../src/differ" +import { + THEME_NAMES, + LANG_NAMES, + getThemeColorsSync, + preload, +} from "@code-hike/lighter" +import React from "react" + +export default function Page() { + const [ready, setReady] = React.useState(false) + + React.useEffect(() => { + preload(["scala", "python"], "github-dark").then(() => { + setReady(true) + }) + }, []) + + if (!ready) { + return ( +
+ ) + } + + return
+} + +function Main() { + const [theme, setTheme] = React.useState("github-dark") + const [fromText, setFromText] = React.useState(code[0]) + const [toText, setToText] = React.useState(code[1]) + const [fromLang, setFromLang] = React.useState("scala") + const [fromLangLoaded, setFromLangLoaded] = + React.useState("scala") + const [toLang, setToLang] = React.useState("python") + const [toLangLoaded, setToLangLoaded] = + React.useState("python") + const [right, setRight] = React.useState(false) + + const themeColors = getThemeColorsSync(theme) + const tokens = React.useMemo(() => { + return right + ? tokenizeSync(toText, toLangLoaded, theme) + : tokenizeSync(fromText, fromLangLoaded, theme) + }, [ + right, + toText, + toLangLoaded, + fromText, + fromLangLoaded, + theme, + ]) + + return ( +
+ + +
+
+ { + const name = e.target.value + setFromLang(name) + if (LANG_NAMES.includes(name)) { + preload([name]).then(() => { + setFromLangLoaded(name) + }) + } + }} + style={{ + color: LANG_NAMES.includes(fromLang) + ? "black" + : "red", + }} + /> +