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 ?? {}),
|
...(mcflyConfig.nitro ?? {}),
|
||||||
...(nitroConfig ?? {}),
|
...(nitroConfig ?? {}),
|
||||||
})
|
})
|
||||||
|
nitro.options.runtimeConfig.mcfly = mcflyConfig
|
||||||
await prepare(nitro)
|
await prepare(nitro)
|
||||||
await copyPublicAssets(nitro)
|
await copyPublicAssets(nitro)
|
||||||
await prerender(nitro)
|
await prerender(nitro)
|
||||||
|
|
|
@ -46,6 +46,7 @@ async function serve(args) {
|
||||||
*/
|
*/
|
||||||
let nitro
|
let nitro
|
||||||
const reload = async () => {
|
const reload = async () => {
|
||||||
|
// close existing nitro
|
||||||
if (nitro) {
|
if (nitro) {
|
||||||
consola.info('Restarting dev server...')
|
consola.info('Restarting dev server...')
|
||||||
if ('unwatch' in nitro.options._c12) {
|
if ('unwatch' in nitro.options._c12) {
|
||||||
|
@ -53,6 +54,8 @@ async function serve(args) {
|
||||||
}
|
}
|
||||||
await nitro.close()
|
await nitro.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// create new nitro
|
||||||
nitro = await createNitro(
|
nitro = await createNitro(
|
||||||
{
|
{
|
||||||
extends: '@mcflyjs/config',
|
extends: '@mcflyjs/config',
|
||||||
|
@ -87,6 +90,7 @@ async function serve(args) {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
nitro.hooks.hookOnce('restart', reload)
|
nitro.hooks.hookOnce('restart', reload)
|
||||||
|
nitro.options.runtimeConfig.mcfly = mcflyConfig
|
||||||
const server = createDevServer(nitro)
|
const server = createDevServer(nitro)
|
||||||
// const listenOptions = parseArgs(args)
|
// const listenOptions = parseArgs(args)
|
||||||
await server.listen(1234)
|
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 { consola } from 'consola'
|
||||||
import { parseScript } from 'esprima'
|
|
||||||
import { loadConfig } from 'c12'
|
|
||||||
import { eventHandler } from 'h3'
|
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('../config').McFlyConfig} Config
|
||||||
* @typedef {import('unstorage').Storage} Storage
|
* @typedef {import('unstorage').Storage} Storage
|
||||||
* @typedef {import('unstorage').StorageValue} StorageValue
|
* @typedef {import('unstorage').StorageValue} StorageValue
|
||||||
* @typedef {import('ultrahtml').Node} HtmlNode
|
|
||||||
* @typedef {import('estree').BaseNode} JsNode
|
|
||||||
* @typedef {import('h3').EventHandler} EventHandler
|
* @typedef {import('h3').EventHandler} EventHandler
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -22,35 +23,58 @@ import { resolve } from 'pathe'
|
||||||
* @returns {EventHandler}
|
* @returns {EventHandler}
|
||||||
*/
|
*/
|
||||||
export function useMcFlyRoute({ storage }) {
|
export function useMcFlyRoute({ storage }) {
|
||||||
|
const hooks = createHooks()
|
||||||
|
Object.keys(mcflyHooks).forEach((hookName) => hooks.addHooks(hookName))
|
||||||
|
|
||||||
return eventHandler(async (event) => {
|
return eventHandler(async (event) => {
|
||||||
const { path } = event
|
const { path } = event
|
||||||
const rootDir = resolve('.')
|
let { config } = await loadConfig({ name: 'mcfly' })
|
||||||
const loadedConfig = await loadConfig({
|
|
||||||
name: 'mcfly',
|
if (!config || Object.keys(config).length === 0) {
|
||||||
configFile: 'mcfly.config',
|
config = defaultMcflyConfig
|
||||||
cwd: rootDir,
|
consola.warn(`[WARN]: McFly configuration not loaded, using defaults...`)
|
||||||
})
|
|
||||||
const config = {
|
|
||||||
components: 'js', // work around for c12.loadConfig not working on Netlify function
|
|
||||||
...loadedConfig.config,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const plugins = config.plugins
|
||||||
|
|
||||||
|
plugins.forEach((plugin) => {
|
||||||
|
const pluginHooks = Object.keys(plugin)
|
||||||
|
pluginHooks.forEach((pluginHook) => {
|
||||||
|
hooks.hook(pluginHook, plugin[pluginHook])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const { components: componentType } = config
|
const { components: componentType } = config
|
||||||
let html = await getHtml(path, storage)
|
let html = await getHtml(path, storage)
|
||||||
|
|
||||||
consola.info('[INFO]: Config found\n', config)
|
|
||||||
consola.info('>>> Current Working Directory: ', rootDir)
|
|
||||||
|
|
||||||
if (html) {
|
if (html) {
|
||||||
const transforms = [evaluateServerScript, deleteServerScripts]
|
const transforms = [
|
||||||
|
{
|
||||||
for (const transform of transforms) {
|
fn: evaluateServerScripts,
|
||||||
html = transform(html.toString())
|
args: [''],
|
||||||
}
|
hook: mcflyHooks.serverScriptsEvaluated,
|
||||||
|
},
|
||||||
html = await useFragments(html.toString(), storage)
|
{
|
||||||
|
fn: injectHtmlFragments,
|
||||||
|
args: [storage],
|
||||||
|
hook: mcflyHooks.fragmentsInjected,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fn: injectCustomElements,
|
||||||
|
args: [componentType, storage],
|
||||||
|
hook: mcflyHooks.customElementsInjected,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
if (!!componentType && !!html) {
|
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 {
|
} else {
|
||||||
consola.error('[ERR]: Failed to insert registry', {
|
consola.error('[ERR]: Failed to insert registry', {
|
||||||
componentType: !componentType ? 'missing' : 'okay',
|
componentType: !componentType ? 'missing' : 'okay',
|
||||||
|
@ -59,6 +83,10 @@ export function useMcFlyRoute({ storage }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (html) {
|
||||||
|
hooks.callHook(mcflyHooks.pageRendered)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
html ??
|
html ??
|
||||||
new Response(
|
new Response(
|
||||||
|
@ -72,7 +100,6 @@ export function useMcFlyRoute({ storage }) {
|
||||||
function getPurePath(path) {
|
function getPurePath(path) {
|
||||||
return path.split('?')[0]
|
return path.split('?')[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the correct HTML depending on the path requested
|
* Gets the correct HTML depending on the path requested
|
||||||
* @param {string} path
|
* @param {string} path
|
||||||
|
@ -101,343 +128,3 @@ async function getHtml(path, storage) {
|
||||||
function getPath(filename) {
|
function getPath(filename) {
|
||||||
return `assets:pages${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",
|
"consola": "^3.3.3",
|
||||||
"esbuild": "^0.24.2",
|
"esbuild": "^0.24.2",
|
||||||
"esprima": "^4.0.1",
|
"esprima": "^4.0.1",
|
||||||
"h3": "^1.8.2",
|
"h3": "^1.13.0",
|
||||||
"nitropack": "~2.10.4",
|
"nitropack": "latest",
|
||||||
"pathe": "^1.1.2",
|
"pathe": "^1.1.2",
|
||||||
"ultrahtml": "^1.5.2"
|
"ultrahtml": "^1.5.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"hookable": "^5.5.3",
|
||||||
"unstorage": "^1.14.4"
|
"unstorage": "^1.14.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -95,18 +95,21 @@ importers:
|
||||||
specifier: ^4.0.1
|
specifier: ^4.0.1
|
||||||
version: 4.0.1
|
version: 4.0.1
|
||||||
h3:
|
h3:
|
||||||
specifier: ^1.8.2
|
specifier: ^1.13.0
|
||||||
version: 1.13.0
|
version: 1.13.0
|
||||||
nitropack:
|
nitropack:
|
||||||
specifier: ~2.10.4
|
specifier: latest
|
||||||
version: 2.10.4(typescript@5.7.2)
|
version: 2.10.4(typescript@5.7.2)
|
||||||
pathe:
|
pathe:
|
||||||
specifier: ^1.1.2
|
specifier: ^1.1.2
|
||||||
version: 1.1.2
|
version: 1.1.2
|
||||||
ultrahtml:
|
ultrahtml:
|
||||||
specifier: ^1.5.2
|
specifier: ^1.5.3
|
||||||
version: 1.5.3
|
version: 1.5.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
hookable:
|
||||||
|
specifier: ^5.5.3
|
||||||
|
version: 5.5.3
|
||||||
unstorage:
|
unstorage:
|
||||||
specifier: ^1.14.4
|
specifier: ^1.14.4
|
||||||
version: 1.14.4(db0@0.2.1)(ioredis@5.4.2)
|
version: 1.14.4(db0@0.2.1)(ioredis@5.4.2)
|
||||||
|
@ -131,18 +134,15 @@ importers:
|
||||||
'@mcflyjs/core':
|
'@mcflyjs/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/core
|
version: link:../packages/core
|
||||||
nitropack:
|
|
||||||
specifier: ~2.10.4
|
|
||||||
version: 2.10.4(typescript@5.7.2)
|
|
||||||
|
|
||||||
templates/basic:
|
templates/basic:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mcflyjs/cli':
|
'@mcflyjs/cli':
|
||||||
specifier: ^0.1.1
|
specifier: ^0.1.1
|
||||||
version: 0.1.1(magicast@0.3.5)
|
version: link:../../packages/cli
|
||||||
'@mcflyjs/config':
|
'@mcflyjs/config':
|
||||||
specifier: ^0.2.1
|
specifier: ^0.2.1
|
||||||
version: 0.2.1
|
version: link:../../packages/config
|
||||||
'@mcflyjs/core':
|
'@mcflyjs/core':
|
||||||
specifier: ^0.6.1
|
specifier: ^0.6.1
|
||||||
version: 0.6.1(magicast@0.3.5)(typescript@5.7.2)
|
version: 0.6.1(magicast@0.3.5)(typescript@5.7.2)
|
||||||
|
@ -622,13 +622,6 @@ packages:
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
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':
|
'@mcflyjs/core@0.6.1':
|
||||||
resolution: {integrity: sha512-ozgpIRpNix7gyuFlB8ofEm+iENMvxQsgyzxMTbIrOM69QYiEV1GsjxWyUqH3QuCwA4UB1KHdyWS+PNaCWSepOQ==}
|
resolution: {integrity: sha512-ozgpIRpNix7gyuFlB8ofEm+iENMvxQsgyzxMTbIrOM69QYiEV1GsjxWyUqH3QuCwA4UB1KHdyWS+PNaCWSepOQ==}
|
||||||
|
|
||||||
|
@ -3139,22 +3132,6 @@ snapshots:
|
||||||
- encoding
|
- encoding
|
||||||
- supports-color
|
- 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)':
|
'@mcflyjs/core@0.6.1(magicast@0.3.5)(typescript@5.7.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
c12: 2.0.1(magicast@0.3.5)
|
c12: 2.0.1(magicast@0.3.5)
|
||||||
|
|
|
@ -5,6 +5,14 @@ export default defineMcFlyConfig({
|
||||||
server: {
|
server: {
|
||||||
logs: true,
|
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: {
|
nitro: {
|
||||||
devServer: {
|
devServer: {
|
||||||
watch: ['../packages'],
|
watch: ['../packages'],
|
||||||
|
|
|
@ -13,8 +13,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mcflyjs/cli": "workspace:*",
|
"@mcflyjs/cli": "workspace:*",
|
||||||
"@mcflyjs/config": "workspace:*",
|
"@mcflyjs/config": "workspace:*",
|
||||||
"@mcflyjs/core": "workspace:*",
|
"@mcflyjs/core": "workspace:*"
|
||||||
"nitropack": "~2.10.4"
|
|
||||||
},
|
},
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|
Loading…
Reference in a new issue