From d87a4260e3d23938be44453bb2ee5c87073fe8f1 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 21 Oct 2023 13:32:22 +0200 Subject: [PATCH] feat: initial packages @mcflyjs/core, @templates/basic --- mcfly.config.ts | 2 +- package-lock.json | 28 ++- package.json | 6 +- packages/[...index].ts | 262 +++++++++++++++++++++++++++ packages/config/index.ts | 4 +- packages/{ => core}/define-config.ts | 0 packages/core/package.json | 24 +++ templates/basic/.editorconfig | 15 ++ templates/basic/.eslintignore | 3 + templates/basic/.eslintrc | 5 + templates/basic/.gitignore | 7 + templates/basic/.npmrc | 2 + templates/basic/README.md | 42 +++++ templates/basic/mcfly.config.ts | 5 + templates/basic/nitro.config.ts | 3 + templates/basic/package.json | 19 ++ templates/basic/routes/[...index].ts | 262 +++++++++++++++++++++++++++ templates/basic/tsconfig.json | 3 + 18 files changed, 684 insertions(+), 8 deletions(-) create mode 100644 packages/[...index].ts rename packages/{ => core}/define-config.ts (100%) create mode 100644 packages/core/package.json create mode 100644 templates/basic/.editorconfig create mode 100644 templates/basic/.eslintignore create mode 100644 templates/basic/.eslintrc create mode 100644 templates/basic/.gitignore create mode 100644 templates/basic/.npmrc create mode 100644 templates/basic/README.md create mode 100644 templates/basic/mcfly.config.ts create mode 100644 templates/basic/nitro.config.ts create mode 100644 templates/basic/package.json create mode 100644 templates/basic/routes/[...index].ts create mode 100644 templates/basic/tsconfig.json diff --git a/mcfly.config.ts b/mcfly.config.ts index 54487ef..45b89fb 100644 --- a/mcfly.config.ts +++ b/mcfly.config.ts @@ -1,4 +1,4 @@ -import defineConfig from "./packages/define-config"; +import defineConfig from "@mcflyjs/core/define-config"; export default defineConfig({ components: "js", diff --git a/package-lock.json b/package-lock.json index adf7f6a..de0a168 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,10 +6,12 @@ "": { "workspaces": [ "packages/config", - "packages/create-mcfly" + "packages/create-mcfly", + "templates/basic", + "packages/core" ], "dependencies": { - "@mcflyjs/config": "*", + "@mcflyjs/config": "./packages/config", "esprima": "^4.0.1", "nitropack": "latest", "ultrahtml": "^1.5.2" @@ -441,6 +443,10 @@ "resolved": "packages/config", "link": true }, + "node_modules/@mcflyjs/core": { + "resolved": "packages/core", + "link": true + }, "node_modules/@netlify/functions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-2.3.0.tgz", @@ -990,6 +996,10 @@ } } }, + "node_modules/@templates/basic": { + "resolved": "templates/basic", + "link": true + }, "node_modules/@types/estree": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.3.tgz", @@ -3955,6 +3965,11 @@ "license": "MIT", "devDependencies": {} }, + "packages/core": { + "version": "0.0.1", + "license": "MIT", + "devDependencies": {} + }, "packages/create-mcfly": { "version": "0.0.4", "license": "MIT", @@ -3962,6 +3977,15 @@ "create-mcfly": "index.js" }, "devDependencies": {} + }, + "templates/basic": { + "name": "@templates/basic", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@mcflyjs/config": "^0.0.1", + "nitropack": "latest" + } } } } diff --git a/package.json b/package.json index cbdb9a7..b8e3677 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,15 @@ "build:preview": "npm run build && npm run preview" }, "dependencies": { - "@mcflyjs/config": "*", + "@mcflyjs/config": "./packages/config", "esprima": "^4.0.1", "nitropack": "latest", "ultrahtml": "^1.5.2" }, "workspaces": [ "packages/config", - "packages/create-mcfly" + "packages/create-mcfly", + "templates/basic", + "packages/core" ] } diff --git a/packages/[...index].ts b/packages/[...index].ts new file mode 100644 index 0000000..6d824a0 --- /dev/null +++ b/packages/[...index].ts @@ -0,0 +1,262 @@ +/** + * McFly SSR logic + */ + +import { ELEMENT_NODE, parse, render, renderSync, walkSync } from "ultrahtml"; +import { parseScript } from "esprima"; +import config from "../mcfly.config"; + +const { components: componentType } = config(); + +export default eventHandler(async (event) => { + const { path } = event; + let html = await getHtml(path); + + // transforms + const transforms = [doSetUp, deleteServerScripts]; + if (html) { + for (const transform of transforms) { + html = transform(html.toString()); + } + } + + html = await useFragments(html.toString()); + + if (!!componentType && !!html) { + html = await insertRegistry(html.toString(), componentType); + } + + return html ?? new Response("Not found", { status: 404 }); +}); + +const getHtml = async (path: string) => { + const rawPath = path[path.length - 1] === "/" ? path.slice(0, -1) : path; + const filename = rawPath === "" ? "/index.html" : `${rawPath}.html`; + const fallback = getPath(rawPath + "/index.html"); + const filePath = getPath(filename); + let html = await useStorage().getItem(filePath); + if (!html) html = await useStorage().getItem(fallback); + if (!html) html = await useStorage().getItem(getPath("/404.html")); + + return html; +}; + +function getPath(filename: string) { + return `assets/pages${filename}`; +} + +async function insertRegistry( + html: string, + type: "js" | "ts" +): Promise { + const ast = parse(html); + const componentFiles = await getFiles(type); + const availableComponents = componentFiles.map((key) => + key.replace(`.${type}`, "") + ); + + const usedCustomElements = []; + + walkSync(ast, (node) => { + const usedElement = availableComponents.find((name) => name === node.name); + + if (node.type === ELEMENT_NODE && !!usedElement) { + usedCustomElements.push(usedElement); + } + }); + + // insert registry script to head + if (usedCustomElements.length > 0) { + const registryScript = await buildRegistry(usedCustomElements, type); + walkSync(ast, (node) => { + if (node.type === ELEMENT_NODE && node.name === "head") { + node.children.push(parse(registryScript)); + } + }); + } + + return render(ast); +} + +async function buildRegistry(usedCustomElements: string[], type: "js" | "ts") { + let registryScript = `"; + + return registryScript; +} + +function doSetUp(html: string) { + const ast = parse(html); + const serverScripts = []; + walkSync(ast, (node) => { + const { attributes } = node; + const attributeKeys = Object.keys(attributes ?? {}); + const isServerScript = attributeKeys.some((key) => key.includes("server:")); + if ( + node.type === ELEMENT_NODE && + node.name === "script" && + isServerScript + ) { + const scripts = node.children.map((child) => child.value); + const script = cleanScript(scripts); + serverScripts.push(script); + } + }); + + const setupMap = {}; + serverScripts.forEach((script: string) => { + const { body } = parseScript(script); + const keys = body + .filter((node) => node.type === "VariableDeclaration") + .map((node) => node.declarations[0].id.name); + const constructor = `(function(){}.constructor)(\`${script}; return {${keys.join( + "," + )}}\`);`; + const evalStore = eval(constructor); + Object.assign(setupMap, new evalStore()); + }); + + const regex = /{{(.*?)}}/g; + var match; + + while ((match = regex.exec(html))) { + let [key, value] = match; + value = value.replace(/\s/g, ""); + html = html.replace(key, setupMap[value]); + } + + return html; +} + +function deleteServerScripts(html: string): string { + const ast = parse(html); + walkSync(ast, (node) => { + const { attributes } = node; + const attributeKeys = Object.keys(attributes ?? {}); + const isServerScript = attributeKeys.some((key) => key.includes("server:")); + if (isServerScript) { + node.parent.children.splice(node.parent.children.indexOf(node), 1); + } + }); + + return renderSync(ast); +} + +function cleanScript(scripts: string[]): string { + let script = scripts.map((s) => s.trim()).join(" "); + + script = removeComments(script); + + return script.replace(/\n/g, "").replace(/\s+/g, " "); +} + +function isComment(node) { + return ( + node.type === "Line" || + node.type === "Block" || + node.type === "BlockComment" || + node.type === "LineComment" + ); +} + +function removeComments(script: string) { + const entries = []; + parseScript(script, { comment: true }, function (node, meta) { + if (isComment(node)) { + entries.push({ + start: meta.start.offset, + end: meta.end.offset, + }); + } + }); + + entries + .sort((a, b) => { + return b.end - a.end; + }) + .forEach((n) => { + script = script.slice(0, n.start) + script.slice(n.end); + }); + return script; +} + +async function useFragments(html: string) { + const fragmentFiles = await getFiles("html"); + + const availableFragments = fragmentFiles.reduce((acc, key) => { + return { + ...acc, + [key.replace(".html", "")]: "", + }; + }, {}); + const ast = parse(html); + + for (const key in availableFragments) { + let text: string = await useStorage().getItem( + "assets:components:" + key + ".html" + ); + availableFragments[key] = text.replace(/\n/g, "").replace(/\s+/g, " "); + } + + walkSync(ast, (node) => { + const selector = Object.keys(availableFragments).find( + (name) => name === node.name + ); + + if (node.type === ELEMENT_NODE && !!selector) { + const index = node.parent.children.indexOf(node); + const fragmentNode = parse(availableFragments[selector]); + + replaceSlots(fragmentNode, node); + + node.parent.children[index] = fragmentNode; + } + }); + + return render(ast); +} + +function replaceSlots(fragmentNode, node) { + walkSync(fragmentNode, (n) => { + if (n.type === ELEMENT_NODE && n.name === "slot") { + const index = n.parent.children.indexOf(n); + n.parent.children.splice(index, 1, ...node.children); + } + }); +} + +async function getFiles(type: string) { + return (await useStorage().getKeys("assets:components")) + .map((key) => key.replace("assets:components:", "")) + .filter((key) => key.includes(type)); +} diff --git a/packages/config/index.ts b/packages/config/index.ts index 329bc2c..6fa5940 100644 --- a/packages/config/index.ts +++ b/packages/config/index.ts @@ -1,6 +1,4 @@ -import { NitroConfig } from "nitropack"; - -export default function McFly(): NitroConfig { +export default function McFly() { return { devServer: { watch: ["./src/pages", "./src/components"], diff --git a/packages/define-config.ts b/packages/core/define-config.ts similarity index 100% rename from packages/define-config.ts rename to packages/core/define-config.ts diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..21f442f --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,24 @@ +{ + "name": "@mcflyjs/core", + "version": "0.0.1", + "description": "McFly core package", + "main": "index.js", + "devDependencies": {}, + "files": [ + "define-config.ts" + ], + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ayoayco/McFly.git", + "directory": "packages/core" + }, + "author": "Ayo Ayco", + "license": "MIT", + "bugs": { + "url": "https://github.com/ayoayco/McFly/issues" + }, + "homepage": "https://github.com/ayoayco/McFly#readme" +} diff --git a/templates/basic/.editorconfig b/templates/basic/.editorconfig new file mode 100644 index 0000000..4f4d652 --- /dev/null +++ b/templates/basic/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[*.js] +indent_style = space +indent_size = 2 + +[{package.json,*.yml,*.cjson}] +indent_style = space +indent_size = 2 diff --git a/templates/basic/.eslintignore b/templates/basic/.eslintignore new file mode 100644 index 0000000..be2e4d2 --- /dev/null +++ b/templates/basic/.eslintignore @@ -0,0 +1,3 @@ +dist +.output +node-modules diff --git a/templates/basic/.eslintrc b/templates/basic/.eslintrc new file mode 100644 index 0000000..e14cbd6 --- /dev/null +++ b/templates/basic/.eslintrc @@ -0,0 +1,5 @@ +{ + "extends": [ + "@nuxtjs/eslint-config-typescript" + ] +} diff --git a/templates/basic/.gitignore b/templates/basic/.gitignore new file mode 100644 index 0000000..eee04ce --- /dev/null +++ b/templates/basic/.gitignore @@ -0,0 +1,7 @@ +node_modules +*.log* +.nitro +.cache +.output +.env +dist \ No newline at end of file diff --git a/templates/basic/.npmrc b/templates/basic/.npmrc new file mode 100644 index 0000000..cf04042 --- /dev/null +++ b/templates/basic/.npmrc @@ -0,0 +1,2 @@ +shamefully-hoist=true +strict-peer-dependencies=false diff --git a/templates/basic/README.md b/templates/basic/README.md new file mode 100644 index 0000000..c35bcf0 --- /dev/null +++ b/templates/basic/README.md @@ -0,0 +1,42 @@ +# Nitro Minimal Starter + +Look at the [Nitro documentation](https://nitro.unjs.io/) to learn more. + +## Setup + +Make sure to install the dependencies: + +```bash +# npm +npm install + +# yarn +yarn install + +# pnpm +pnpm install +``` + +## Development Server + +Start the development server on + +```bash +npm run dev +``` + +## Production + +Build the application for production: + +```bash +npm run build +``` + +Locally preview production build: + +```bash +npm run preview +``` + +Check out the [deployment documentation](https://nitro.unjs.io/deploy) for more information. diff --git a/templates/basic/mcfly.config.ts b/templates/basic/mcfly.config.ts new file mode 100644 index 0000000..54487ef --- /dev/null +++ b/templates/basic/mcfly.config.ts @@ -0,0 +1,5 @@ +import defineConfig from "./packages/define-config"; + +export default defineConfig({ + components: "js", +}); diff --git a/templates/basic/nitro.config.ts b/templates/basic/nitro.config.ts new file mode 100644 index 0000000..17773e4 --- /dev/null +++ b/templates/basic/nitro.config.ts @@ -0,0 +1,3 @@ +import McFly from "@mcflyjs/config"; + +export default defineNitroConfig({ ...McFly() }); diff --git a/templates/basic/package.json b/templates/basic/package.json new file mode 100644 index 0000000..9b66974 --- /dev/null +++ b/templates/basic/package.json @@ -0,0 +1,19 @@ +{ + "private": true, + "scripts": { + "prepare": "nitropack prepare", + "dev": "nitropack dev", + "build": "nitropack build", + "preview": "node .output/server/index.mjs" + }, + "dependencies": { + "@mcflyjs/config": "^0.0.1", + "nitropack": "latest" + }, + "name": "@templates/basic", + "description": "Look at the [Nitro documentation](https://nitro.unjs.io/) to learn more.", + "version": "0.0.1", + "main": "index.js", + "author": "Ayo Ayco", + "license": "MIT" +} diff --git a/templates/basic/routes/[...index].ts b/templates/basic/routes/[...index].ts new file mode 100644 index 0000000..5d4b1ae --- /dev/null +++ b/templates/basic/routes/[...index].ts @@ -0,0 +1,262 @@ +/** + * McFly SSR logic + */ + +import { ELEMENT_NODE, parse, render, renderSync, walkSync } from "ultrahtml"; +import { parseScript } from "esprima"; +import config from "../../../mcfly.config"; + +const { components: componentType } = config(); + +export default eventHandler(async (event) => { + const { path } = event; + let html = await getHtml(path); + + // transforms + const transforms = [doSetUp, deleteServerScripts]; + if (html) { + for (const transform of transforms) { + html = transform(html.toString()); + } + } + + html = await useFragments(html.toString()); + + if (!!componentType && !!html) { + html = await insertRegistry(html.toString(), componentType); + } + + return html ?? new Response("Not found", { status: 404 }); +}); + +const getHtml = async (path: string) => { + const rawPath = path[path.length - 1] === "/" ? path.slice(0, -1) : path; + const filename = rawPath === "" ? "/index.html" : `${rawPath}.html`; + const fallback = getPath(rawPath + "/index.html"); + const filePath = getPath(filename); + let html = await useStorage().getItem(filePath); + if (!html) html = await useStorage().getItem(fallback); + if (!html) html = await useStorage().getItem(getPath("/404.html")); + + return html; +}; + +function getPath(filename: string) { + return `assets/pages${filename}`; +} + +async function insertRegistry( + html: string, + type: "js" | "ts" +): Promise { + const ast = parse(html); + const componentFiles = await getFiles(type); + const availableComponents = componentFiles.map((key) => + key.replace(`.${type}`, "") + ); + + const usedCustomElements = []; + + walkSync(ast, (node) => { + const usedElement = availableComponents.find((name) => name === node.name); + + if (node.type === ELEMENT_NODE && !!usedElement) { + usedCustomElements.push(usedElement); + } + }); + + // insert registry script to head + if (usedCustomElements.length > 0) { + const registryScript = await buildRegistry(usedCustomElements, type); + walkSync(ast, (node) => { + if (node.type === ELEMENT_NODE && node.name === "head") { + node.children.push(parse(registryScript)); + } + }); + } + + return render(ast); +} + +async function buildRegistry(usedCustomElements: string[], type: "js" | "ts") { + let registryScript = `"; + + return registryScript; +} + +function doSetUp(html: string) { + const ast = parse(html); + const serverScripts = []; + walkSync(ast, (node) => { + const { attributes } = node; + const attributeKeys = Object.keys(attributes ?? {}); + const isServerScript = attributeKeys.some((key) => key.includes("server:")); + if ( + node.type === ELEMENT_NODE && + node.name === "script" && + isServerScript + ) { + const scripts = node.children.map((child) => child.value); + const script = cleanScript(scripts); + serverScripts.push(script); + } + }); + + const setupMap = {}; + serverScripts.forEach((script: string) => { + const { body } = parseScript(script); + const keys = body + .filter((node) => node.type === "VariableDeclaration") + .map((node) => node.declarations[0].id.name); + const constructor = `(function(){}.constructor)(\`${script}; return {${keys.join( + "," + )}}\`);`; + const evalStore = eval(constructor); + Object.assign(setupMap, new evalStore()); + }); + + const regex = /{{(.*?)}}/g; + var match; + + while ((match = regex.exec(html))) { + let [key, value] = match; + value = value.replace(/\s/g, ""); + html = html.replace(key, setupMap[value]); + } + + return html; +} + +function deleteServerScripts(html: string): string { + const ast = parse(html); + walkSync(ast, (node) => { + const { attributes } = node; + const attributeKeys = Object.keys(attributes ?? {}); + const isServerScript = attributeKeys.some((key) => key.includes("server:")); + if (isServerScript) { + node.parent.children.splice(node.parent.children.indexOf(node), 1); + } + }); + + return renderSync(ast); +} + +function cleanScript(scripts: string[]): string { + let script = scripts.map((s) => s.trim()).join(" "); + + script = removeComments(script); + + return script.replace(/\n/g, "").replace(/\s+/g, " "); +} + +function isComment(node) { + return ( + node.type === "Line" || + node.type === "Block" || + node.type === "BlockComment" || + node.type === "LineComment" + ); +} + +function removeComments(script: string) { + const entries = []; + parseScript(script, { comment: true }, function (node, meta) { + if (isComment(node)) { + entries.push({ + start: meta.start.offset, + end: meta.end.offset, + }); + } + }); + + entries + .sort((a, b) => { + return b.end - a.end; + }) + .forEach((n) => { + script = script.slice(0, n.start) + script.slice(n.end); + }); + return script; +} + +async function useFragments(html: string) { + const fragmentFiles = await getFiles("html"); + + const availableFragments = fragmentFiles.reduce((acc, key) => { + return { + ...acc, + [key.replace(".html", "")]: "", + }; + }, {}); + const ast = parse(html); + + for (const key in availableFragments) { + let text: string = await useStorage().getItem( + "assets:components:" + key + ".html" + ); + availableFragments[key] = text.replace(/\n/g, "").replace(/\s+/g, " "); + } + + walkSync(ast, (node) => { + const selector = Object.keys(availableFragments).find( + (name) => name === node.name + ); + + if (node.type === ELEMENT_NODE && !!selector) { + const index = node.parent.children.indexOf(node); + const fragmentNode = parse(availableFragments[selector]); + + replaceSlots(fragmentNode, node); + + node.parent.children[index] = fragmentNode; + } + }); + + return render(ast); +} + +function replaceSlots(fragmentNode, node) { + walkSync(fragmentNode, (n) => { + if (n.type === ELEMENT_NODE && n.name === "slot") { + const index = n.parent.children.indexOf(n); + n.parent.children.splice(index, 1, ...node.children); + } + }); +} + +async function getFiles(type: string) { + return (await useStorage().getKeys("assets:components")) + .map((key) => key.replace("assets:components:", "")) + .filter((key) => key.includes(type)); +} diff --git a/templates/basic/tsconfig.json b/templates/basic/tsconfig.json new file mode 100644 index 0000000..43008af --- /dev/null +++ b/templates/basic/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nitro/types/tsconfig.json" +}