diff --git a/app/routes/[...index].ts b/app/routes/[...index].ts index 6d824a0..f5e0c55 100644 --- a/app/routes/[...index].ts +++ b/app/routes/[...index].ts @@ -1,262 +1,6 @@ /** * McFly SSR logic */ - -import { ELEMENT_NODE, parse, render, renderSync, walkSync } from "ultrahtml"; -import { parseScript } from "esprima"; +import McFly from "@mcflyjs/core"; 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)); -} +export default McFly(config, useStorage); diff --git a/package-lock.json b/package-lock.json index 9cb0cc4..3ac5445 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3981,10 +3981,15 @@ "name": "@mcflyjs/core", "version": "0.0.1", "license": "MIT", - "devDependencies": {} + "dependencies": { + "esprima": "^4.0.1", + "h3": "^1.8.2", + "nitropack": "^2.7.0", + "ultrahtml": "^1.5.2" + } }, "packages/create-mcfly": { - "version": "0.0.4", + "version": "0.0.5", "license": "MIT", "bin": { "create-mcfly": "index.js" diff --git a/packages/core/index.ts b/packages/core/index.ts new file mode 100644 index 0000000..def1327 --- /dev/null +++ b/packages/core/index.ts @@ -0,0 +1,268 @@ +import { eventHandler } from "h3"; +import { ELEMENT_NODE, parse, render, renderSync, walkSync } from "ultrahtml"; +import { parseScript } from "esprima"; + +export default (config: Function, useStorage) => { + const { componentType } = config(); + return eventHandler(async (event) => { + const { path } = event; + let html = await getHtml(path, useStorage); + + // transforms + const transforms = [doSetUp, deleteServerScripts]; + if (html) { + for (const transform of transforms) { + html = transform(html.toString()); + } + } + + html = await useFragments(html.toString(), useStorage); + + if (!!componentType && !!html) { + html = await insertRegistry(html.toString(), componentType, useStorage); + } + + return html ?? new Response("Not found", { status: 404 }); + }); +}; + +const getHtml = async (path: string, useStorage) => { + 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", + useStorage +): Promise { + const ast = parse(html); + const componentFiles = await getFiles(type, useStorage); + 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, + useStorage + ); + 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", + useStorage +) { + 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, useStorage) { + const fragmentFiles = await getFiles("html", useStorage); + + 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, useStorage) { + return (await useStorage().getKeys("assets:components")) + .map((key) => key.replace("assets:components:", "")) + .filter((key) => key.includes(type)); +} diff --git a/packages/core/package.json b/packages/core/package.json index 21f442f..dc802fb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,9 +2,9 @@ "name": "@mcflyjs/core", "version": "0.0.1", "description": "McFly core package", - "main": "index.js", - "devDependencies": {}, + "main": "index.ts", "files": [ + "index.ts", "define-config.ts" ], "scripts": { @@ -20,5 +20,11 @@ "bugs": { "url": "https://github.com/ayoayco/McFly/issues" }, - "homepage": "https://github.com/ayoayco/McFly#readme" + "homepage": "https://github.com/ayoayco/McFly#readme", + "dependencies": { + "esprima": "^4.0.1", + "h3": "^1.8.2", + "nitropack": "^2.7.0", + "ultrahtml": "^1.5.2" + } } diff --git a/packages/create-mcfly/package.json b/packages/create-mcfly/package.json index a67df78..20b954f 100644 --- a/packages/create-mcfly/package.json +++ b/packages/create-mcfly/package.json @@ -1,6 +1,6 @@ { "name": "create-mcfly", - "version": "0.0.4", + "version": "0.0.5", "bin": { "create-mcfly": "./index.js" },