feat: integration hooks system (#54)
* refactor: modularize event-handler transform functions * feat: add plugin hooks from mcfly config
This commit is contained in:
parent
4c69aa6cc0
commit
f08ce4c043
13 changed files with 458 additions and 403 deletions
|
@ -29,6 +29,7 @@ async function _build(args) {
|
|||
...(mcflyConfig.nitro ?? {}),
|
||||
...(nitroConfig ?? {}),
|
||||
})
|
||||
nitro.options.runtimeConfig.mcfly = mcflyConfig
|
||||
await prepare(nitro)
|
||||
await copyPublicAssets(nitro)
|
||||
await prerender(nitro)
|
||||
|
|
|
@ -46,6 +46,7 @@ async function serve(args) {
|
|||
*/
|
||||
let nitro
|
||||
const reload = async () => {
|
||||
// close existing nitro
|
||||
if (nitro) {
|
||||
consola.info('Restarting dev server...')
|
||||
if ('unwatch' in nitro.options._c12) {
|
||||
|
@ -53,6 +54,8 @@ async function serve(args) {
|
|||
}
|
||||
await nitro.close()
|
||||
}
|
||||
|
||||
// create new nitro
|
||||
nitro = await createNitro(
|
||||
{
|
||||
extends: '@mcflyjs/config',
|
||||
|
@ -87,6 +90,7 @@ async function serve(args) {
|
|||
}
|
||||
)
|
||||
nitro.hooks.hookOnce('restart', reload)
|
||||
nitro.options.runtimeConfig.mcfly = mcflyConfig
|
||||
const server = createDevServer(nitro)
|
||||
// const listenOptions = parseArgs(args)
|
||||
await server.listen(1234)
|
||||
|
|
3
packages/core/default-mcfly-config.js
Normal file
3
packages/core/default-mcfly-config.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default {
|
||||
components: 'js',
|
||||
}
|
162
packages/core/evaluate-scripts.js
Normal file
162
packages/core/evaluate-scripts.js
Normal file
|
@ -0,0 +1,162 @@
|
|||
import { ELEMENT_NODE, parse, renderSync, walkSync } from 'ultrahtml'
|
||||
import { parseScript } from 'esprima'
|
||||
import { consola } from 'consola'
|
||||
|
||||
/**
|
||||
* @typedef {import('estree').BaseNode} JsNode
|
||||
*/
|
||||
|
||||
/**
|
||||
* McFly HTML Template parser
|
||||
* @param {*} _html
|
||||
* @returns {string}
|
||||
*/
|
||||
export function evaluateServerScripts(_html) {
|
||||
let html = evaluateServerScript(_html)
|
||||
html = deleteServerScripts(html)
|
||||
return html
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates server:setup script and replaces all variables used in the HTML
|
||||
* @param {string} html
|
||||
* @returns {string}
|
||||
*/
|
||||
function evaluateServerScript(html) {
|
||||
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) => {
|
||||
const { body } = parseScript(script)
|
||||
const keys = body
|
||||
.filter((n) => n.type === 'VariableDeclaration')
|
||||
.map((n) => n['declarations'][0].id.name)
|
||||
const constructor = `(function(){}.constructor)(\`${script}; return {${keys.join(
|
||||
','
|
||||
)}}\`);`
|
||||
const evalStore = eval(constructor)
|
||||
Object.assign(setupMap, new evalStore())
|
||||
})
|
||||
|
||||
const regex = /\{\{(.*?)\}\}/g
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(html))) {
|
||||
let [key, rawValue] = match
|
||||
|
||||
const value = rawValue.replace(/\s/g, '')
|
||||
const keys = value.split('.')
|
||||
let finalValue = ''
|
||||
let setupCopy = setupMap
|
||||
|
||||
// if not in the server script, it could be a js expression
|
||||
if (!(keys[0] in setupMap)) {
|
||||
try {
|
||||
finalValue = eval(rawValue)
|
||||
} catch (e) {
|
||||
consola.error('[ERR]: Failed to evaluate expression', e)
|
||||
}
|
||||
}
|
||||
|
||||
// nested objects
|
||||
keys.forEach((key) => {
|
||||
if (key in setupCopy) {
|
||||
finalValue = setupCopy[key]
|
||||
setupCopy = finalValue
|
||||
}
|
||||
})
|
||||
|
||||
html = html.replace(key, finalValue ?? '')
|
||||
regex.lastIndex = -1
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes any instance of server:setup script in the HTML
|
||||
* @param {string} html
|
||||
* @returns {string}
|
||||
*/
|
||||
function deleteServerScripts(html) {
|
||||
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) {
|
||||
node.parent.children.splice(node.parent.children.indexOf(node), 1)
|
||||
}
|
||||
})
|
||||
|
||||
return renderSync(ast)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans a JS string for save evaluation
|
||||
* @param {Array<string>} scripts
|
||||
* @returns {string}
|
||||
*/
|
||||
function cleanScript(scripts) {
|
||||
let script = scripts.map((s) => s.trim()).join(' ')
|
||||
|
||||
script = removeComments(script)
|
||||
|
||||
return script //.replace(/\n/g, '').replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all instances of comments in a JS string
|
||||
* @param {string} script
|
||||
* @returns {string}
|
||||
*/
|
||||
function removeComments(script) {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if given node of a JS script is a comment
|
||||
* @param {JsNode} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isComment(node) {
|
||||
return (
|
||||
node.type === 'Line' ||
|
||||
node.type === 'Block' ||
|
||||
node.type === 'BlockComment' ||
|
||||
node.type === 'LineComment'
|
||||
)
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
import { ELEMENT_NODE, parse, render, renderSync, walkSync } from 'ultrahtml'
|
||||
import { consola } from 'consola'
|
||||
import { parseScript } from 'esprima'
|
||||
import { loadConfig } from 'c12'
|
||||
import { eventHandler } from 'h3'
|
||||
import { resolve } from 'pathe'
|
||||
import { createHooks } from 'hookable'
|
||||
import { hooks as mcflyHooks } from './hooks.js'
|
||||
import { evaluateServerScripts } from './evaluate-scripts.js'
|
||||
import { injectHtmlFragments } from './inject-fragments.js'
|
||||
import { injectCustomElements } from './inject-elements.js'
|
||||
import defaultMcflyConfig from './default-mcfly-config.js'
|
||||
import { loadConfig } from 'c12'
|
||||
|
||||
/**
|
||||
* @typedef {import('../config').McFlyConfig} Config
|
||||
* @typedef {import('unstorage').Storage} Storage
|
||||
* @typedef {import('unstorage').StorageValue} StorageValue
|
||||
* @typedef {import('ultrahtml').Node} HtmlNode
|
||||
* @typedef {import('estree').BaseNode} JsNode
|
||||
* @typedef {import('h3').EventHandler} EventHandler
|
||||
*/
|
||||
|
||||
|
@ -22,35 +23,58 @@ import { resolve } from 'pathe'
|
|||
* @returns {EventHandler}
|
||||
*/
|
||||
export function useMcFlyRoute({ storage }) {
|
||||
const hooks = createHooks()
|
||||
Object.keys(mcflyHooks).forEach((hookName) => hooks.addHooks(hookName))
|
||||
|
||||
return eventHandler(async (event) => {
|
||||
const { path } = event
|
||||
const rootDir = resolve('.')
|
||||
const loadedConfig = await loadConfig({
|
||||
name: 'mcfly',
|
||||
configFile: 'mcfly.config',
|
||||
cwd: rootDir,
|
||||
})
|
||||
const config = {
|
||||
components: 'js', // work around for c12.loadConfig not working on Netlify function
|
||||
...loadedConfig.config,
|
||||
let { config } = await loadConfig({ name: 'mcfly' })
|
||||
|
||||
if (!config || Object.keys(config).length === 0) {
|
||||
config = defaultMcflyConfig
|
||||
consola.warn(`[WARN]: McFly configuration not loaded, using defaults...`)
|
||||
}
|
||||
|
||||
const plugins = config.plugins
|
||||
|
||||
plugins.forEach((plugin) => {
|
||||
const pluginHooks = Object.keys(plugin)
|
||||
pluginHooks.forEach((pluginHook) => {
|
||||
hooks.hook(pluginHook, plugin[pluginHook])
|
||||
})
|
||||
})
|
||||
|
||||
const { components: componentType } = config
|
||||
let html = await getHtml(path, storage)
|
||||
|
||||
consola.info('[INFO]: Config found\n', config)
|
||||
consola.info('>>> Current Working Directory: ', rootDir)
|
||||
|
||||
if (html) {
|
||||
const transforms = [evaluateServerScript, deleteServerScripts]
|
||||
|
||||
for (const transform of transforms) {
|
||||
html = transform(html.toString())
|
||||
}
|
||||
|
||||
html = await useFragments(html.toString(), storage)
|
||||
const transforms = [
|
||||
{
|
||||
fn: evaluateServerScripts,
|
||||
args: [''],
|
||||
hook: mcflyHooks.serverScriptsEvaluated,
|
||||
},
|
||||
{
|
||||
fn: injectHtmlFragments,
|
||||
args: [storage],
|
||||
hook: mcflyHooks.fragmentsInjected,
|
||||
},
|
||||
{
|
||||
fn: injectCustomElements,
|
||||
args: [componentType, storage],
|
||||
hook: mcflyHooks.customElementsInjected,
|
||||
},
|
||||
]
|
||||
|
||||
if (!!componentType && !!html) {
|
||||
html = await insertRegistry(html.toString(), componentType, storage)
|
||||
for (const transform of transforms) {
|
||||
html = await transform.fn(html.toString(), ...transform.args)
|
||||
|
||||
// call hook
|
||||
if (transform.hook) {
|
||||
hooks.callHook(transform.hook)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
consola.error('[ERR]: Failed to insert registry', {
|
||||
componentType: !componentType ? 'missing' : 'okay',
|
||||
|
@ -59,6 +83,10 @@ export function useMcFlyRoute({ storage }) {
|
|||
}
|
||||
}
|
||||
|
||||
if (html) {
|
||||
hooks.callHook(mcflyHooks.pageRendered)
|
||||
}
|
||||
|
||||
return (
|
||||
html ??
|
||||
new Response(
|
||||
|
@ -72,7 +100,6 @@ export function useMcFlyRoute({ storage }) {
|
|||
function getPurePath(path) {
|
||||
return path.split('?')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the correct HTML depending on the path requested
|
||||
* @param {string} path
|
||||
|
@ -101,343 +128,3 @@ async function getHtml(path, storage) {
|
|||
function getPath(filename) {
|
||||
return `assets:pages${filename}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns transformed HTML with custom elements registry in the head
|
||||
* @param {string} html
|
||||
* @param {Config['components']} type
|
||||
* @param {Storage} storage
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function insertRegistry(html, type, storage) {
|
||||
const ast = parse(html)
|
||||
const componentFiles = await getFiles(type, storage)
|
||||
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,
|
||||
storage
|
||||
)
|
||||
walkSync(ast, (node) => {
|
||||
if (node.type === ELEMENT_NODE && node.name === 'head') {
|
||||
node.children.push(parse(registryScript))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return render(ast)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the string containing all custom elements definition
|
||||
* @param {Array<string>} usedCustomElements
|
||||
* @param {Config['components']} type
|
||||
* @param {Storage} storage
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function buildRegistry(usedCustomElements, type, storage) {
|
||||
let registryScript = `<script type='module'>`
|
||||
let isBaseClassImported = false
|
||||
let classesImported = []
|
||||
|
||||
for (const name of usedCustomElements) {
|
||||
const content = await storage.getItem(`assets:components:${name}.${type}`)
|
||||
if (!content) continue
|
||||
const evalStore = eval(
|
||||
`class WebComponent {}; class HTMLElement {}; (${content.toString()})`
|
||||
)
|
||||
|
||||
if (isConstructor(evalStore)) {
|
||||
const className = new evalStore().constructor.name
|
||||
|
||||
if (!classesImported.includes(className)) {
|
||||
if (
|
||||
!isBaseClassImported &&
|
||||
content.toString().includes('extends WebComponent')
|
||||
) {
|
||||
const baseClassImport = `import { WebComponent, html, attachEffect } from "https://unpkg.com/web-component-base@2.0.6/index.js";`
|
||||
registryScript += baseClassImport
|
||||
isBaseClassImported = true
|
||||
}
|
||||
|
||||
registryScript += content
|
||||
|
||||
registryScript += `customElements.define("${name}", ${className});`
|
||||
classesImported.push(className)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registryScript += '</script>'
|
||||
|
||||
return registryScript
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if function is a constructor
|
||||
* @param {function} f
|
||||
* @returns boolean
|
||||
*/
|
||||
function isConstructor(f) {
|
||||
try {
|
||||
new f()
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (err) {
|
||||
// TODO: verify err is the expected error and then
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates server:setup script and replaces all variables used in the HTML
|
||||
* @param {string} html
|
||||
* @returns {string}
|
||||
*/
|
||||
function evaluateServerScript(html) {
|
||||
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) => {
|
||||
const { body } = parseScript(script)
|
||||
const keys = body
|
||||
.filter((n) => n.type === 'VariableDeclaration')
|
||||
.map((n) => n['declarations'][0].id.name)
|
||||
const constructor = `(function(){}.constructor)(\`${script}; return {${keys.join(
|
||||
','
|
||||
)}}\`);`
|
||||
const evalStore = eval(constructor)
|
||||
Object.assign(setupMap, new evalStore())
|
||||
})
|
||||
|
||||
const regex = /\{\{(.*?)\}\}/g
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(html))) {
|
||||
let [key, rawValue] = match
|
||||
|
||||
const value = rawValue.replace(/\s/g, '')
|
||||
const keys = value.split('.')
|
||||
let finalValue = ''
|
||||
let setupCopy = setupMap
|
||||
|
||||
// if not in the server script, it could be a js expression
|
||||
if (!(keys[0] in setupMap)) {
|
||||
try {
|
||||
finalValue = eval(rawValue)
|
||||
} catch (e) {
|
||||
consola.error('[ERR]: Failed to evaluate expression', e)
|
||||
}
|
||||
}
|
||||
|
||||
// nested objects
|
||||
keys.forEach((key) => {
|
||||
if (key in setupCopy) {
|
||||
finalValue = setupCopy[key]
|
||||
setupCopy = finalValue
|
||||
}
|
||||
})
|
||||
|
||||
html = html.replace(key, finalValue ?? '')
|
||||
regex.lastIndex = -1
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes any instance of server:setup script in the HTML
|
||||
* @param {string} html
|
||||
* @returns {string}
|
||||
*/
|
||||
function deleteServerScripts(html) {
|
||||
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) {
|
||||
node.parent.children.splice(node.parent.children.indexOf(node), 1)
|
||||
}
|
||||
})
|
||||
|
||||
return renderSync(ast)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans a JS string for save evaluation
|
||||
* @param {Array<string>} scripts
|
||||
* @returns {string}
|
||||
*/
|
||||
function cleanScript(scripts) {
|
||||
let script = scripts.map((s) => s.trim()).join(' ')
|
||||
|
||||
script = removeComments(script)
|
||||
|
||||
return script //.replace(/\n/g, '').replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if given node of a JS script is a comment
|
||||
* @param {JsNode} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isComment(node) {
|
||||
return (
|
||||
node.type === 'Line' ||
|
||||
node.type === 'Block' ||
|
||||
node.type === 'BlockComment' ||
|
||||
node.type === 'LineComment'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all instances of comments in a JS string
|
||||
* @param {string} script
|
||||
* @returns {string}
|
||||
*/
|
||||
function removeComments(script) {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Return HTML with all fragments replaced with the correct content in the storage
|
||||
* @param {string} html
|
||||
* @param {Storage} storage
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function useFragments(html, storage) {
|
||||
const fragmentFiles = await getFiles('html', storage)
|
||||
|
||||
const availableFragments = fragmentFiles.reduce((acc, key) => {
|
||||
return {
|
||||
...acc,
|
||||
[key.replace('.html', '')]: '',
|
||||
}
|
||||
}, {})
|
||||
const ast = parse(html)
|
||||
|
||||
for (const key in availableFragments) {
|
||||
/**
|
||||
* @type string | null
|
||||
*/
|
||||
let text = await storage.getItem('assets:components:' + key + '.html')
|
||||
if (!text) continue
|
||||
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)
|
||||
/**
|
||||
* @type {HtmlNode}
|
||||
*/
|
||||
const fragmentNode = parse(availableFragments[selector])
|
||||
|
||||
replaceSlots(fragmentNode, node)
|
||||
|
||||
node.parent.children[index] = fragmentNode
|
||||
}
|
||||
})
|
||||
|
||||
return render(ast)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a slot in a fragmentNode with given node
|
||||
* @param {HtmlNode} fragmentNode
|
||||
* @param {HtmlNode} node
|
||||
* @returns {void}
|
||||
*/
|
||||
function replaceSlots(fragmentNode, node) {
|
||||
let slotted = []
|
||||
const containsAll = (arr, target) => target.every((v) => arr.includes(v))
|
||||
walkSync(fragmentNode, (n) => {
|
||||
if (n.type === ELEMENT_NODE && n.name === 'slot') {
|
||||
// find node child with same name attribute
|
||||
const currentSlotName = n.attributes?.['name'] ?? null
|
||||
let nodeChildren = []
|
||||
|
||||
if (currentSlotName === null) {
|
||||
nodeChildren = node.children.filter(
|
||||
(child) => !child.attributes?.['slot']
|
||||
)
|
||||
} else {
|
||||
nodeChildren = node.children.filter((child) => {
|
||||
const childSlotName = child.attributes?.['slot']
|
||||
return childSlotName === currentSlotName
|
||||
})
|
||||
}
|
||||
|
||||
if (nodeChildren.length > 0 && !containsAll(slotted, nodeChildren)) {
|
||||
slotted = [...slotted, ...nodeChildren]
|
||||
const index = n.parent.children.indexOf(n)
|
||||
n.parent.children.splice(index, 1, ...nodeChildren)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all files from the storage given a type
|
||||
* @param {string} type
|
||||
* @param {Storage} storage
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async function getFiles(type, storage) {
|
||||
return (await storage.getKeys('assets:components'))
|
||||
.map((key) => key.replace('assets:components:', ''))
|
||||
.filter((key) => key.includes(type))
|
||||
}
|
||||
|
|
11
packages/core/get-files.js
Normal file
11
packages/core/get-files.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Get all files from the storage given a type
|
||||
* @param {string} type
|
||||
* @param {Storage} storage
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function getFiles(type, storage) {
|
||||
return (await storage.getKeys('assets:components'))
|
||||
.map((key) => key.replace('assets:components:', ''))
|
||||
.filter((key) => key.includes(type))
|
||||
}
|
6
packages/core/hooks.js
Normal file
6
packages/core/hooks.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export const hooks = {
|
||||
pageRendered: 'mcfly:page:rendered',
|
||||
serverScriptsEvaluated: 'mcfly:scripts:evaluated',
|
||||
fragmentsInjected: 'mcfly:fragments:injected',
|
||||
customElementsInjected: 'mcfly:elements:injected',
|
||||
}
|
108
packages/core/inject-elements.js
Normal file
108
packages/core/inject-elements.js
Normal file
|
@ -0,0 +1,108 @@
|
|||
import { ELEMENT_NODE, parse, render, walkSync } from 'ultrahtml'
|
||||
import { getFiles } from './get-files.js'
|
||||
|
||||
/**
|
||||
* @typedef {import('../config').McFlyConfig} Config
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns transformed HTML with custom elements registry in the head
|
||||
* @param {string} html
|
||||
* @param {Config['components']} type
|
||||
* @param {Storage} storage
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function injectCustomElements(html, type, storage) {
|
||||
const ast = parse(html)
|
||||
const componentFiles = await getFiles(type, storage)
|
||||
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,
|
||||
storage
|
||||
)
|
||||
walkSync(ast, (node) => {
|
||||
if (node.type === ELEMENT_NODE && node.name === 'head') {
|
||||
node.children.push(parse(registryScript))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return render(ast)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the string containing all custom elements definition
|
||||
* @param {Array<string>} usedCustomElements
|
||||
* @param {Config['components']} type
|
||||
* @param {Storage} storage
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function buildRegistry(usedCustomElements, type, storage) {
|
||||
let registryScript = `<script type='module'>`
|
||||
let isBaseClassImported = false
|
||||
let classesImported = []
|
||||
|
||||
for (const name of usedCustomElements) {
|
||||
const content = await storage.getItem(`assets:components:${name}.${type}`)
|
||||
if (!content) continue
|
||||
const evalStore = eval(
|
||||
`class WebComponent {}; class HTMLElement {}; (${content.toString()})`
|
||||
)
|
||||
|
||||
if (isConstructor(evalStore)) {
|
||||
const className = new evalStore().constructor.name
|
||||
|
||||
if (!classesImported.includes(className)) {
|
||||
if (
|
||||
!isBaseClassImported &&
|
||||
content.toString().includes('extends WebComponent')
|
||||
) {
|
||||
const baseClassImport = `import { WebComponent, html, attachEffect } from "https://unpkg.com/web-component-base@2.0.6/index.js";`
|
||||
registryScript += baseClassImport
|
||||
isBaseClassImported = true
|
||||
}
|
||||
|
||||
registryScript += content
|
||||
|
||||
registryScript += `customElements.define("${name}", ${className});`
|
||||
classesImported.push(className)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registryScript += '</script>'
|
||||
|
||||
return registryScript
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if function is a constructor
|
||||
* @param {function} f
|
||||
* @returns boolean
|
||||
*/
|
||||
function isConstructor(f) {
|
||||
try {
|
||||
new f()
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (err) {
|
||||
// TODO: verify err is the expected error and then
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
88
packages/core/inject-fragments.js
Normal file
88
packages/core/inject-fragments.js
Normal file
|
@ -0,0 +1,88 @@
|
|||
import { ELEMENT_NODE, parse, render, walkSync } from 'ultrahtml'
|
||||
import { getFiles } from './get-files.js'
|
||||
|
||||
/**
|
||||
* @typedef {import('ultrahtml').Node} HtmlNode
|
||||
*/
|
||||
|
||||
/**
|
||||
* Return HTML with all fragments replaced with the correct content in the storage
|
||||
* @param {string} html
|
||||
* @param {Storage} storage
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function injectHtmlFragments(html, storage) {
|
||||
const fragmentFiles = await getFiles('html', storage)
|
||||
|
||||
const availableFragments = fragmentFiles.reduce((acc, key) => {
|
||||
return {
|
||||
...acc,
|
||||
[key.replace('.html', '')]: '',
|
||||
}
|
||||
}, {})
|
||||
const ast = parse(html)
|
||||
|
||||
for (const key in availableFragments) {
|
||||
/**
|
||||
* @type string | null
|
||||
*/
|
||||
let text = await storage.getItem('assets:components:' + key + '.html')
|
||||
if (!text) continue
|
||||
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)
|
||||
/**
|
||||
* @type {HtmlNode}
|
||||
*/
|
||||
const fragmentNode = parse(availableFragments[selector])
|
||||
|
||||
replaceSlots(fragmentNode, node)
|
||||
|
||||
node.parent.children[index] = fragmentNode
|
||||
}
|
||||
})
|
||||
|
||||
return render(ast)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a slot in a fragmentNode with given node
|
||||
* @param {HtmlNode} fragmentNode
|
||||
* @param {HtmlNode} node
|
||||
* @returns {void}
|
||||
*/
|
||||
function replaceSlots(fragmentNode, node) {
|
||||
let slotted = []
|
||||
const containsAll = (arr, target) => target.every((v) => arr.includes(v))
|
||||
walkSync(fragmentNode, (n) => {
|
||||
if (n.type === ELEMENT_NODE && n.name === 'slot') {
|
||||
// find node child with same name attribute
|
||||
const currentSlotName = n.attributes?.['name'] ?? null
|
||||
let nodeChildren = []
|
||||
|
||||
if (currentSlotName === null) {
|
||||
nodeChildren = node.children.filter(
|
||||
(child) => !child.attributes?.['slot']
|
||||
)
|
||||
} else {
|
||||
nodeChildren = node.children.filter((child) => {
|
||||
const childSlotName = child.attributes?.['slot']
|
||||
return childSlotName === currentSlotName
|
||||
})
|
||||
}
|
||||
|
||||
if (nodeChildren.length > 0 && !containsAll(slotted, nodeChildren)) {
|
||||
slotted = [...slotted, ...nodeChildren]
|
||||
const index = n.parent.children.indexOf(n)
|
||||
n.parent.children.splice(index, 1, ...nodeChildren)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
|
@ -25,12 +25,13 @@
|
|||
"consola": "^3.3.3",
|
||||
"esbuild": "^0.24.2",
|
||||
"esprima": "^4.0.1",
|
||||
"h3": "^1.8.2",
|
||||
"nitropack": "~2.10.4",
|
||||
"h3": "^1.13.0",
|
||||
"nitropack": "latest",
|
||||
"pathe": "^1.1.2",
|
||||
"ultrahtml": "^1.5.2"
|
||||
"ultrahtml": "^1.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"hookable": "^5.5.3",
|
||||
"unstorage": "^1.14.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -95,18 +95,21 @@ importers:
|
|||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
h3:
|
||||
specifier: ^1.8.2
|
||||
specifier: ^1.13.0
|
||||
version: 1.13.0
|
||||
nitropack:
|
||||
specifier: ~2.10.4
|
||||
specifier: latest
|
||||
version: 2.10.4(typescript@5.7.2)
|
||||
pathe:
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2
|
||||
ultrahtml:
|
||||
specifier: ^1.5.2
|
||||
specifier: ^1.5.3
|
||||
version: 1.5.3
|
||||
devDependencies:
|
||||
hookable:
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.3
|
||||
unstorage:
|
||||
specifier: ^1.14.4
|
||||
version: 1.14.4(db0@0.2.1)(ioredis@5.4.2)
|
||||
|
@ -131,18 +134,15 @@ importers:
|
|||
'@mcflyjs/core':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/core
|
||||
nitropack:
|
||||
specifier: ~2.10.4
|
||||
version: 2.10.4(typescript@5.7.2)
|
||||
|
||||
templates/basic:
|
||||
dependencies:
|
||||
'@mcflyjs/cli':
|
||||
specifier: ^0.1.1
|
||||
version: 0.1.1(magicast@0.3.5)
|
||||
version: link:../../packages/cli
|
||||
'@mcflyjs/config':
|
||||
specifier: ^0.2.1
|
||||
version: 0.2.1
|
||||
version: link:../../packages/config
|
||||
'@mcflyjs/core':
|
||||
specifier: ^0.6.1
|
||||
version: 0.6.1(magicast@0.3.5)(typescript@5.7.2)
|
||||
|
@ -622,13 +622,6 @@ packages:
|
|||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@mcflyjs/cli@0.1.1':
|
||||
resolution: {integrity: sha512-YT0XVwbUO9SscfoCrNY1Tz92SwLrCKbvHYrPUWjC8UAVaaX8tekdfRDF7DiIr8PX5me3YIZUBnQw0/loFNPhvQ==}
|
||||
hasBin: true
|
||||
|
||||
'@mcflyjs/config@0.2.1':
|
||||
resolution: {integrity: sha512-u+4iKQtId3P8qvopl9p04gX2a/ahSmmrLx/uFp9bvg6UA6Y9tIhPG8l713QH7LVNr4iSIybukeuYbHxYHLiHYQ==}
|
||||
|
||||
'@mcflyjs/core@0.6.1':
|
||||
resolution: {integrity: sha512-ozgpIRpNix7gyuFlB8ofEm+iENMvxQsgyzxMTbIrOM69QYiEV1GsjxWyUqH3QuCwA4UB1KHdyWS+PNaCWSepOQ==}
|
||||
|
||||
|
@ -3139,22 +3132,6 @@ snapshots:
|
|||
- encoding
|
||||
- supports-color
|
||||
|
||||
'@mcflyjs/cli@0.1.1(magicast@0.3.5)':
|
||||
dependencies:
|
||||
c12: 2.0.1(magicast@0.3.5)
|
||||
citty: 0.1.6
|
||||
consola: 3.3.3
|
||||
pathe: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
- magicast
|
||||
|
||||
'@mcflyjs/config@0.2.1':
|
||||
dependencies:
|
||||
h3: 1.13.0
|
||||
web-component-base: 2.1.2
|
||||
transitivePeerDependencies:
|
||||
- uWebSockets.js
|
||||
|
||||
'@mcflyjs/core@0.6.1(magicast@0.3.5)(typescript@5.7.2)':
|
||||
dependencies:
|
||||
c12: 2.0.1(magicast@0.3.5)
|
||||
|
|
|
@ -5,6 +5,14 @@ export default defineMcFlyConfig({
|
|||
server: {
|
||||
logs: true,
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
'mcfly:page:rendered': () => console.log('>>> page rendered'),
|
||||
'mcfly:scripts:evaluated': () => console.log('>>> scripts evaluated'),
|
||||
'mcfly:fragments:injected': () => console.log('>>> fragments injected'),
|
||||
'mcfly:elements:injected': () => console.log('>>> elements injected'),
|
||||
},
|
||||
],
|
||||
nitro: {
|
||||
devServer: {
|
||||
watch: ['../packages'],
|
||||
|
|
|
@ -13,8 +13,7 @@
|
|||
"dependencies": {
|
||||
"@mcflyjs/cli": "workspace:*",
|
||||
"@mcflyjs/config": "workspace:*",
|
||||
"@mcflyjs/core": "workspace:*",
|
||||
"nitropack": "~2.10.4"
|
||||
"@mcflyjs/core": "workspace:*"
|
||||
},
|
||||
"version": "0.0.1",
|
||||
"main": "index.js",
|
||||
|
|
Loading…
Reference in a new issue