diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/README.md b/README.md index 255eec0..cf677db 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,15 @@ # yarjs +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.1.16. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..e02b481 Binary files /dev/null and b/bun.lockb differ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..9e38baa --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,10 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default [ + { files: ["**/*.{js,mjs,cjs,ts}"] }, + { languageOptions: { globals: globals.browser } }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, +]; diff --git a/index.html b/index.html new file mode 100644 index 0000000..e6025da --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + TS + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..6da01ea --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "yarjs", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@eslint/js": "^9.5.0", + "@types/bun": "latest", + "@types/react": "^18.3.3", + "eslint": "9.x", + "globals": "^15.6.0", + "typescript": "^5.2.2", + "typescript-eslint": "^7.13.1", + "vite": "^5.3.1" + }, + "module": "index.ts" +} diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/YarJS.ts b/src/lib/YarJS.ts new file mode 100644 index 0000000..93ae5cc --- /dev/null +++ b/src/lib/YarJS.ts @@ -0,0 +1,164 @@ +import { + YarElement, + YarFiber, + YarHTMLTagName, + YarProps, +} from "./YarJs.interfaces"; + +export function createTextElement(text: string) { + return { + type: "TEXT", + props: { + nodeValue: text, + children: [], + }, + }; +} + +export function createElement( + type: YarHTMLTagName, + props: YarProps, + ...children: YarElement[] +) { + return { + type, + props: { + ...props, + children: children.map((child) => + typeof child === "object" ? child : createTextElement(child), + ), + }, + }; +} + +function createDom(fiber: YarFiber) { + const dom = + fiber.type === "TEXT" + ? document.createTextNode("") + : document.createElement(fiber.type); + + Object.keys(fiber.props) + .filter((key) => key !== "children") + .forEach((key) => { + // @ts-expect-error: I cannot figure it out how to properly type the dom props + dom[key] = fiber.props[key]; + }); + + return dom; +} + +let nextUnitOfWork: YarFiber | null | undefined = null; +let wipRoot: YarFiber | null | undefined = null; + +function commitWork(fiber: YarFiber | null) { + if (!fiber) return; + + const domParent = fiber.parent!.dom!; + domParent.appendChild(fiber.dom!); + commitWork(fiber.child); + commitWork(fiber.sibling); +} + +function commitRoot() { + if (!wipRoot) return; + + commitWork(wipRoot.child); + wipRoot = null; +} + +function workLoop(deadline: IdleDeadline) { + let shouldYield = false; + while (nextUnitOfWork && !shouldYield) { + nextUnitOfWork = performUnitOfWork(nextUnitOfWork); + shouldYield = deadline.timeRemaining() < 1; + } + + if (!nextUnitOfWork && wipRoot) { + commitRoot(); + } + + requestIdleCallback(workLoop); +} + +requestIdleCallback(workLoop); + +function performUnitOfWork(fiber: YarFiber) { + // Process the Fiber Tree, this is a representation of the DOM + // Since this this process could be interrupted before the whole + // tree is processed, we just do the representation and computation here, + // then in "commitWork" we add the representation to the actual DOM + + if (!fiber.dom) { + // Create the actual dom element + fiber.dom = createDom(fiber); + } + + // Create a new fiber for each child + const elements = fiber.props.children; + let index = 0; + let prevSibling: YarFiber | null = null; + + while (index < elements.length) { + const element = elements[index]; + + const newFiber = { + parent: fiber, + type: element.type, + props: element.props, + child: null, + sibling: null, + dom: null, + }; + + // Each Fiber only holds a reference to it's first child (in the Fiber Tree representation), + // so if is the first new Fiber we add it as a child to the parent, + // if is not, we add as a sibling of the last child + // Nonetheless, each fiber has a reference to it's parent + if (index === 0) { + fiber.child = newFiber; + } else if (prevSibling !== null) { + prevSibling.sibling = newFiber; + } + + prevSibling = newFiber; + index++; + } + + // Search for the new fiber that needs to be processed + if (fiber.child) { + // return the first child of the current fiber + return fiber.child; + } + + let nextFiber = fiber; + + while (nextFiber) { + if (nextFiber.sibling) { + // return the next sibling of the current fiber + return nextFiber.sibling; + } + + // Reference the parent so we look at the "uncle" (parent sibling) + // in the next itereation + nextFiber = nextFiber.parent!; + } +} + +export function render(element: React.JSX.Element, container: HTMLElement) { + wipRoot = { + type: "", + dom: container, + parent: null, + child: null, + sibling: null, + props: { + children: [element], + }, + }; + + nextUnitOfWork = wipRoot; +} + +export function Fragment() {} + +export default { render, createElement, Fragment }; diff --git a/src/lib/YarJs.interfaces.ts b/src/lib/YarJs.interfaces.ts new file mode 100644 index 0000000..974c502 --- /dev/null +++ b/src/lib/YarJs.interfaces.ts @@ -0,0 +1,18 @@ +export type YarHTMLTagName = keyof React.JSX.IntrinsicElements | string; + +export type YarProps = { + children: YarElement[]; + [key: string]: unknown; +}; + +export interface YarElement { + type: YarHTMLTagName; + props: YarProps; +} + +export interface YarFiber extends YarElement { + parent: null | YarFiber; + dom: null | HTMLElement | Text; + child: null | YarFiber; + sibling: null | YarFiber; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..0a7e63d --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,13 @@ +import "./style.css"; +import { render } from "./lib/YarJS"; + +const root = document.querySelector("#app")!; + +const element = ( +
+ bar + +
+); + +render(element, root); diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..f9c7350 --- /dev/null +++ b/src/style.css @@ -0,0 +1,96 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.vanilla:hover { + filter: drop-shadow(0 0 2em #3178c6aa); +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b084023 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "jsx": "preserve", + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + "paths": { + "@/*": ["./*"] + }, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..eab331a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +// vite.config.js +import { defineConfig } from "vite"; + +export default defineConfig({ + esbuild: { + jsxFactory: "createElement", + jsxFragment: "Fragment", + jsxInject: `import {createElement, Fragment} from './lib/YarJS'`, + }, +});