Remove unused dependencies, misc. small fixes

This commit is contained in:
Thomas Rory Gummerson 2025-01-21 09:38:14 +01:00
parent 0bebbd9b23
commit 2b4381fa3c
15 changed files with 118 additions and 1205 deletions

View File

@ -1,6 +1,5 @@
const { join, dirname } = require("path"); const { join, dirname } = require("node:path");
const { promises: fs } = require("node:fs");
const { promises: fs } = require("fs");
const api = require("./api"); const api = require("./api");
@ -26,19 +25,19 @@ const Api = output =>
const Redoc = output => const Redoc = output =>
fs.copyFile(join( fs.copyFile(join(
dirname(require.resolve('redoc')), dirname(require.resolve("redoc")),
'redoc.standalone.js'), "redoc.standalone.js"),
output); output);
module.exports = (async () => { module.exports = (async () => {
const out = join(__dirname, 'static'); const out = join(__dirname, "static");
const apiFile = 'api.json'; const apiFile = "api.json";
const redocFile = 'redoc.js'; const redocFile = "redoc.js";
await fs.mkdir(out, { recursive: true }); await fs.mkdir(out, { recursive: true });
return Promise.all([ return Promise.all([
Api(join(out, apiFile)), Api(join(out, apiFile)),
Redoc(join(out, redocFile)), Redoc(join(out, redocFile)),
Template(join(out, 'index.html'), { Template(join(out, "index.html"), {
api: apiFile, api: apiFile,
title: api.info.title, title: api.info.title,
redoc: redocFile redoc: redocFile

1123
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,8 +4,8 @@
"description": "Modern URL shortener.", "description": "Modern URL shortener.",
"main": "./server/server.js", "main": "./server/server.js",
"scripts": { "scripts": {
"dev": "cross-env NODE_ENV=development node --watch-path=./server --watch-path=./custom server/server.js", "dev": "node --env-file-if-exists=.env --watch-path=./server --watch-path=./custom server/server.js",
"start": "cross-env NODE_ENV=production node server/server.js", "start": "node --env-file-if-exists=.env server/server.js --production",
"migrate": "knex migrate:latest", "migrate": "knex migrate:latest",
"migrate:make": "knex migrate:make", "migrate:make": "knex migrate:make",
"docs:build": "cd docs/api && node generate && cd ../.." "docs:build": "cd docs/api && node generate && cd ../.."
@ -25,12 +25,11 @@
"homepage": "https://github.com/thedevs-network/kutt#readme", "homepage": "https://github.com/thedevs-network/kutt#readme",
"dependencies": { "dependencies": {
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"better-sqlite3": "^11.8.1",
"bull": "4.16.5", "bull": "4.16.5",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"cors": "2.8.5", "cors": "2.8.5",
"cross-env": "7.0.3",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"dotenv": "16.0.3",
"envalid": "8.0.0", "envalid": "8.0.0",
"express": "4.21.2", "express": "4.21.2",
"express-rate-limit": "7.5.0", "express-rate-limit": "7.5.0",
@ -45,8 +44,6 @@
"ms": "2.1.3", "ms": "2.1.3",
"mysql2": "3.12.0", "mysql2": "3.12.0",
"nanoid": "3.3.8", "nanoid": "3.3.8",
"node-cron": "3.0.3",
"node-mailer": "0.1.1",
"nodemailer": "6.9.16", "nodemailer": "6.9.16",
"passport": "0.7.0", "passport": "0.7.0",
"passport-jwt": "4.0.1", "passport-jwt": "4.0.1",
@ -55,9 +52,7 @@
"pg": "8.13.1", "pg": "8.13.1",
"pg-query-stream": "4.7.1", "pg-query-stream": "4.7.1",
"rate-limit-redis": "4.2.0", "rate-limit-redis": "4.2.0",
"sqlite3": "5.1.7", "useragent": "2.3.0"
"useragent": "2.3.0",
"uuid": "10.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "2.4.2", "@types/bcryptjs": "2.4.2",
@ -68,7 +63,6 @@
"@types/jsonwebtoken": "7.2.8", "@types/jsonwebtoken": "7.2.8",
"@types/ms": "0.7.31", "@types/ms": "0.7.31",
"@types/node": "18.11.9", "@types/node": "18.11.9",
"@types/node-cron": "2.0.2",
"@types/nodemailer": "6.4.6", "@types/nodemailer": "6.4.6",
"@types/pg": "8.11.10", "@types/pg": "8.11.10",
"redoc": "2.2.0" "redoc": "2.2.0"

View File

@ -1,10 +1,7 @@
const cron = require("node-cron");
const query = require("./queries"); const query = require("./queries");
const utils = require("./utils"); const utils = require("./utils");
const env = require("./env");
// check and delete links 30 secoonds // check and delete links 30 secoonds
cron.schedule("*/30 * * * * *", function() { setInterval(function () {
query.link.batchRemove({ expire_in: ["<", utils.dateToUTC(new Date())] }).catch(); query.link.batchRemove({ expire_in: ["<", utils.dateToUTC(new Date())] }).catch();
}); }, 30_000);

View File

@ -1,4 +1,3 @@
require("dotenv").config();
const { cleanEnv, num, str, bool } = require("envalid"); const { cleanEnv, num, str, bool } = require("envalid");
const supportedDBClients = [ const supportedDBClients = [
@ -20,6 +19,9 @@ if (process.env.JWT_SECRET === "") {
delete process.env.JWT_SECRET; delete process.env.JWT_SECRET;
} }
// if NODE_ENV is not already set, set it based on --production argument
process.env.NODE_ENV ??= process.argv.includes("--production") ? "production" : "development";
const env = cleanEnv(process.env, { const env = cleanEnv(process.env, {
PORT: num({ default: 3000 }), PORT: num({ default: 3000 }),
SITE_NAME: str({ example: "Kutt", default: "Kutt" }), SITE_NAME: str({ example: "Kutt", default: "Kutt" }),
@ -27,7 +29,7 @@ const env = cleanEnv(process.env, {
LINK_LENGTH: num({ default: 6 }), LINK_LENGTH: num({ default: 6 }),
LINK_CUSTOM_ALPHABET: str({ default: "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789" }), LINK_CUSTOM_ALPHABET: str({ default: "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789" }),
TRUST_PROXY: bool({ default: true }), TRUST_PROXY: bool({ default: true }),
DB_CLIENT: str({ choices: supportedDBClients, default: "sqlite3" }), DB_CLIENT: str({ choices: supportedDBClients, default: "better-sqlite3" }),
DB_FILENAME: str({ default: "db/data" }), DB_FILENAME: str({ default: "db/data" }),
DB_HOST: str({ default: "localhost" }), DB_HOST: str({ default: "localhost" }),
DB_PORT: num({ default: 5432 }), DB_PORT: num({ default: 5432 }),

View File

@ -1,7 +1,7 @@
const { differenceInDays, differenceInMinutes, addMinutes, subMinutes } = require("date-fns"); const { differenceInDays, addMinutes } = require("date-fns");
const { nanoid } = require("nanoid"); const { nanoid } = require("nanoid");
const passport = require("passport"); const passport = require("passport");
const { v4: uuid } = require("uuid"); const { randomUUID } = require("node:crypto");
const bcrypt = require("bcryptjs"); const bcrypt = require("bcryptjs");
const { ROLES } = require("../consts"); const { ROLES } = require("../consts");
@ -227,7 +227,7 @@ async function resetPassword(req, res) {
const user = await query.user.update( const user = await query.user.update(
{ email: req.body.email }, { email: req.body.email },
{ {
reset_password_token: uuid(), reset_password_token: randomUUID(),
reset_password_expires: utils.dateToUTC(addMinutes(new Date(), 30)) reset_password_expires: utils.dateToUTC(addMinutes(new Date(), 30))
} }
); );
@ -298,7 +298,7 @@ async function changeEmailRequest(req, res) {
{ id: req.user.id }, { id: req.user.id },
{ {
change_email_address: email, change_email_address: email,
change_email_token: uuid(), change_email_token: randomUUID(),
change_email_expires: utils.dateToUTC(addMinutes(new Date(), 30)) change_email_expires: utils.dateToUTC(addMinutes(new Date(), 30))
} }
); );

View File

@ -1,9 +1,9 @@
const { differenceInSeconds } = require("date-fns"); const { differenceInSeconds } = require("date-fns");
const promisify = require("util").promisify; const promisify = require("node:util").promisify;
const bcrypt = require("bcryptjs"); const bcrypt = require("bcryptjs");
const { isbot } = require("isbot"); const { isbot } = require("isbot");
const URL = require("url"); const URL = require("node:url");
const dns = require("dns"); const dns = require("node:dns");
const validators = require("./validators.handler"); const validators = require("./validators.handler");
const map = require("../utils/map.json"); const map = require("../utils/map.json");

View File

@ -1,9 +1,9 @@
const { isAfter, subDays, subHours, addMilliseconds, differenceInHours } = require("date-fns"); const { addMilliseconds } = require("date-fns");
const { body, param, query: queryValidator } = require("express-validator"); const { body, param, query: queryValidator } = require("express-validator");
const promisify = require("util").promisify; const promisify = require("node:util").promisify;
const bcrypt = require("bcryptjs"); const bcrypt = require("bcryptjs");
const dns = require("dns"); const dns = require("node:dns");
const URL = require("url"); const URL = require("node:url");
const ms = require("ms"); const ms = require("ms");
const { ROLES } = require("../consts"); const { ROLES } = require("../consts");

View File

@ -1,6 +1,6 @@
const nodemailer = require("nodemailer"); const nodemailer = require("nodemailer");
const path = require("path"); const path = require("node:path");
const fs = require("fs"); const fs = require("node:fs");
const { resetMailText, verifyMailText, changeEmailText } = require("./text"); const { resetMailText, verifyMailText, changeEmailText } = require("./text");
const { CustomError } = require("../utils"); const { CustomError } = require("../utils");

View File

@ -1,5 +1,5 @@
const { addMinutes } = require("date-fns"); const { addMinutes } = require("date-fns");
const { v4: uuid } = require("uuid"); const { randomUUID } = require("node:crypto");
const { ROLES } = require("../consts"); const { ROLES } = require("../consts");
const utils = require("../utils"); const utils = require("../utils");
@ -42,7 +42,7 @@ async function add(params, user) {
password: params.password, password: params.password,
...(params.role && { role: params.role }), ...(params.role && { role: params.role }),
...(params.verified !== undefined && { verified: params.verified }), ...(params.verified !== undefined && { verified: params.verified }),
verification_token: uuid(), verification_token: randomUUID(),
verification_expires: utils.dateToUTC(addMinutes(new Date(), 60)) verification_expires: utils.dateToUTC(addMinutes(new Date(), 60))
}; };
@ -180,7 +180,7 @@ async function getAdmin(match, params) {
async function totalAdmin(match, params) { async function totalAdmin(match, params) {
const query = knex("users") const query = knex("users")
.count("* as count") .count("* as count")
.fromRaw('users') .fromRaw("users")
.where(normalizeMatch(match)); .where(normalizeMatch(match));
if (params?.search) { if (params?.search) {

View File

@ -1,5 +1,5 @@
const Queue = require("bull"); const Queue = require("bull");
const path = require("path"); const path = require("node:path");
const env = require("../env"); const env = require("../env");
@ -19,8 +19,8 @@ if (env.REDIS_ENABLED) {
visit.on("completed", job => job.remove()); visit.on("completed", job => job.remove());
// TODO: handler error // TODO: handler error
// visit.on('error', function (error) { // visit.on("error", function (error) {
// console.log('error'); // console.log("error");
// }); // });
} else { } else {
const visitProcessor = require(path.resolve(__dirname, "visit.js")); const visitProcessor = require(path.resolve(__dirname, "visit.js"));

View File

@ -1,6 +1,6 @@
const useragent = require("useragent"); const useragent = require("useragent");
const geoip = require("geoip-lite"); const geoip = require("geoip-lite");
const URL = require("url"); const URL = require("node:url");
const { removeWww } = require("../utils"); const { removeWww } = require("../utils");
const query = require("../queries"); const query = require("../queries");

View File

@ -4,7 +4,7 @@ const cookieParser = require("cookie-parser");
const passport = require("passport"); const passport = require("passport");
const express = require("express"); const express = require("express");
const helmet = require("helmet"); const helmet = require("helmet");
const path = require("path"); const path = require("node:path");
const hbs = require("hbs"); const hbs = require("hbs");
const helpers = require("./handlers/helpers.handler"); const helpers = require("./handlers/helpers.handler");

View File

@ -360,7 +360,7 @@ function registerHandlebarsHelpers() {
}); });
hbs.registerHelper("block", function(name) { hbs.registerHelper("block", function(name) {
const val = (blocks[name] || []).join('\n'); const val = (blocks[name] || []).join("\n");
blocks[name] = []; blocks[name] = [];
return val; return val;
}); });

View File

@ -2,7 +2,7 @@
// htmx.logAll(); // htmx.logAll();
// add text/html accept header to receive html instead of json for the requests // add text/html accept header to receive html instead of json for the requests
document.body.addEventListener('htmx:configRequest', function(evt) { document.body.addEventListener("htmx:configRequest", function(evt) {
evt.detail.headers["Accept"] = "text/html,*/*"; evt.detail.headers["Accept"] = "text/html,*/*";
}); });
@ -21,8 +21,8 @@ function resetForm(id) {
form.reset(); form.reset();
} }
} }
document.body.addEventListener('resetChangePasswordForm', resetForm("change-password")); document.body.addEventListener("resetChangePasswordForm", resetForm("change-password"));
document.body.addEventListener('resetChangeEmailForm', resetForm("change-email")); document.body.addEventListener("resetChangeEmailForm", resetForm("change-email"));
// an htmx extension to use the specifed params in the path instead of the query or body // an htmx extension to use the specifed params in the path instead of the query or body
htmx.defineExtension("path-params", { htmx.defineExtension("path-params", {
@ -31,7 +31,7 @@ htmx.defineExtension("path-params", {
evt.detail.path = evt.detail.path.replace(/{([^}]+)}/g, function(_, param) { evt.detail.path = evt.detail.path.replace(/{([^}]+)}/g, function(_, param) {
var val = evt.detail.parameters[param] var val = evt.detail.parameters[param]
delete evt.detail.parameters[param] delete evt.detail.parameters[param]
return val === undefined ? '{' + param + '}' : encodeURIComponent(val) return val === undefined ? "{" + param + "}" : encodeURIComponent(val)
}) })
} }
} }
@ -145,8 +145,8 @@ window.addEventListener("click", function(event) {
// handle navigation in the table of links // handle navigation in the table of links
function setLinksLimit(event) { function setLinksLimit(event) {
const buttons = Array.from(document.querySelectorAll('table .nav .limit button')); const buttons = Array.from(document.querySelectorAll("table .nav .limit button"));
const limitInput = document.querySelector('#limit'); const limitInput = document.querySelector("#limit");
if (!limitInput || !buttons || !buttons.length) return; if (!limitInput || !buttons || !buttons.length) return;
limitInput.value = event.target.textContent; limitInput.value = event.target.textContent;
buttons.forEach(b => { buttons.forEach(b => {
@ -155,56 +155,56 @@ function setLinksLimit(event) {
} }
function setLinksSkip(event, action) { function setLinksSkip(event, action) {
const buttons = Array.from(document.querySelectorAll('table .nav .pagination button')); const buttons = Array.from(document.querySelectorAll("table .nav .pagination button"));
const limitElm = document.querySelector('#limit'); const limitElm = document.querySelector("#limit");
const totalElm = document.querySelector('#total'); const totalElm = document.querySelector("#total");
const skipElm = document.querySelector('#skip'); const skipElm = document.querySelector("#skip");
if (!buttons || !limitElm || !totalElm || !skipElm) return; if (!buttons || !limitElm || !totalElm || !skipElm) return;
const skip = parseInt(skipElm.value); const skip = parseInt(skipElm.value);
const limit = parseInt(limitElm.value); const limit = parseInt(limitElm.value);
const total = parseInt(totalElm.value); const total = parseInt(totalElm.value);
skipElm.value = action === "next" ? skip + limit : Math.max(skip - limit, 0); skipElm.value = action === "next" ? skip + limit : Math.max(skip - limit, 0);
document.querySelectorAll('.pagination .next').forEach(elm => { document.querySelectorAll(".pagination .next").forEach(elm => {
elm.disabled = total <= parseInt(skipElm.value) + limit; elm.disabled = total <= parseInt(skipElm.value) + limit;
}); });
document.querySelectorAll('.pagination .prev').forEach(elm => { document.querySelectorAll(".pagination .prev").forEach(elm => {
elm.disabled = parseInt(skipElm.value) <= 0; elm.disabled = parseInt(skipElm.value) <= 0;
}); });
} }
function updateLinksNav() { function updateLinksNav() {
const totalElm = document.querySelector('#total'); const totalElm = document.querySelector("#total");
const skipElm = document.querySelector('#skip'); const skipElm = document.querySelector("#skip");
const limitElm = document.querySelector('#limit'); const limitElm = document.querySelector("#limit");
if (!totalElm || !skipElm || !limitElm) return; if (!totalElm || !skipElm || !limitElm) return;
const total = parseInt(totalElm.value); const total = parseInt(totalElm.value);
const skip = parseInt(skipElm.value); const skip = parseInt(skipElm.value);
const limit = parseInt(limitElm.value); const limit = parseInt(limitElm.value);
document.querySelectorAll('.pagination .next').forEach(elm => { document.querySelectorAll(".pagination .next").forEach(elm => {
elm.disabled = total <= skip + limit; elm.disabled = total <= skip + limit;
}); });
document.querySelectorAll('.pagination .prev').forEach(elm => { document.querySelectorAll(".pagination .prev").forEach(elm => {
elm.disabled = skip <= 0; elm.disabled = skip <= 0;
}); });
} }
function resetTableNav() { function resetTableNav() {
const totalElm = document.querySelector('#total'); const totalElm = document.querySelector("#total");
const skipElm = document.querySelector('#skip'); const skipElm = document.querySelector("#skip");
const limitElm = document.querySelector('#limit'); const limitElm = document.querySelector("#limit");
if (!totalElm || !skipElm || !limitElm) return; if (!totalElm || !skipElm || !limitElm) return;
skipElm.value = 0; skipElm.value = 0;
limitElm.value = 10; limitElm.value = 10;
const total = parseInt(totalElm.value); const total = parseInt(totalElm.value);
const skip = parseInt(skipElm.value); const skip = parseInt(skipElm.value);
const limit = parseInt(limitElm.value); const limit = parseInt(limitElm.value);
document.querySelectorAll('.pagination .next').forEach(elm => { document.querySelectorAll(".pagination .next").forEach(elm => {
elm.disabled = total <= skip + limit; elm.disabled = total <= skip + limit;
}); });
document.querySelectorAll('.pagination .prev').forEach(elm => { document.querySelectorAll(".pagination .prev").forEach(elm => {
elm.disabled = skip <= 0; elm.disabled = skip <= 0;
}); });
document.querySelectorAll('table .nav .limit button').forEach(b => { document.querySelectorAll("table .nav .limit button").forEach(b => {
b.disabled = b.textContent === limit.toString(); b.disabled = b.textContent === limit.toString();
}); });
} }
@ -261,43 +261,43 @@ onSearchInputLoad();
// create user checkbox control // create user checkbox control
function canSendVerificationEmail() { function canSendVerificationEmail() {
const canSendVerificationEmail = !document.getElementById('create-user-verified').checked && !document.getElementById('create-user-banned').checked; const canSendVerificationEmail = !document.getElementById("create-user-verified").checked && !document.getElementById("create-user-banned").checked;
const checkbox = document.getElementById('send-email-label'); const checkbox = document.getElementById("send-email-label");
if (canSendVerificationEmail) if (canSendVerificationEmail)
checkbox.classList.remove('hidden'); checkbox.classList.remove("hidden");
if (!canSendVerificationEmail && !checkbox.classList.contains('hidden')) if (!canSendVerificationEmail && !checkbox.classList.contains("hidden"))
checkbox.classList.add('hidden'); checkbox.classList.add("hidden");
} }
// htmx prefetch extension // htmx prefetch extension
// https://github.com/bigskysoftware/htmx-extensions/blob/main/src/preload/README.md // https://github.com/bigskysoftware/htmx-extensions/blob/main/src/preload/README.md
htmx.defineExtension('preload', { htmx.defineExtension("preload", {
onEvent: function(name, event) { onEvent: function(name, event) {
if (name !== 'htmx:afterProcessNode') { if (name !== "htmx:afterProcessNode") {
return return
} }
var attr = function(node, property) { var attr = function(node, property) {
if (node == undefined) { return undefined } if (node == undefined) { return undefined }
return node.getAttribute(property) || node.getAttribute('data-' + property) || attr(node.parentElement, property) return node.getAttribute(property) || node.getAttribute("data-" + property) || attr(node.parentElement, property)
} }
var load = function(node) { var load = function(node) {
var done = function(html) { var done = function(html) {
if (!node.preloadAlways) { if (!node.preloadAlways) {
node.preloadState = 'DONE' node.preloadState = "DONE"
} }
if (attr(node, 'preload-images') == 'true') { if (attr(node, "preload-images") == "true") {
document.createElement('div').innerHTML = html document.createElement("div").innerHTML = html
} }
} }
return function() { return function() {
if (node.preloadState !== 'READY') { if (node.preloadState !== "READY") {
return return
} }
var hxGet = node.getAttribute('hx-get') || node.getAttribute('data-hx-get') var hxGet = node.getAttribute("hx-get") || node.getAttribute("data-hx-get")
if (hxGet) { if (hxGet) {
htmx.ajax('GET', hxGet, { htmx.ajax("GET", hxGet, {
source: node, source: node,
handler: function(elt, info) { handler: function(elt, info) {
done(info.xhr.responseText) done(info.xhr.responseText)
@ -305,30 +305,30 @@ htmx.defineExtension('preload', {
}) })
return return
} }
if (node.getAttribute('href')) { if (node.getAttribute("href")) {
var r = new XMLHttpRequest() var r = new XMLHttpRequest()
r.open('GET', node.getAttribute('href')) r.open("GET", node.getAttribute("href"))
r.onload = function() { done(r.responseText) } r.onload = function() { done(r.responseText) }
r.send() r.send()
} }
} }
} }
var init = function(node) { var init = function(node) {
if (node.getAttribute('href') + node.getAttribute('hx-get') + node.getAttribute('data-hx-get') == '') { if (node.getAttribute("href") + node.getAttribute("hx-get") + node.getAttribute("data-hx-get") == "") {
return return
} }
if (node.preloadState !== undefined) { if (node.preloadState !== undefined) {
return return
} }
var on = attr(node, 'preload') || 'mousedown' var on = attr(node, "preload") || "mousedown"
const always = on.indexOf('always') !== -1 const always = on.indexOf("always") !== -1
if (always) { if (always) {
on = on.replace('always', '').trim() on = on.replace("always", "").trim()
} }
node.addEventListener(on, function(evt) { node.addEventListener(on, function(evt) {
if (node.preloadState === 'PAUSE') { if (node.preloadState === "PAUSE") {
node.preloadState = 'READY' node.preloadState = "READY"
if (on === 'mouseover') { if (on === "mouseover") {
window.setTimeout(load(node), 100) window.setTimeout(load(node), 100)
} else { } else {
load(node)() load(node)()
@ -336,27 +336,27 @@ htmx.defineExtension('preload', {
} }
}) })
switch (on) { switch (on) {
case 'mouseover': case "mouseover":
node.addEventListener('touchstart', load(node)) node.addEventListener("touchstart", load(node))
node.addEventListener('mouseout', function(evt) { node.addEventListener("mouseout", function(evt) {
if ((evt.target === node) && (node.preloadState === 'READY')) { if ((evt.target === node) && (node.preloadState === "READY")) {
node.preloadState = 'PAUSE' node.preloadState = "PAUSE"
} }
}) })
break break
case 'mousedown': case "mousedown":
node.addEventListener('touchstart', load(node)) node.addEventListener("touchstart", load(node))
break break
} }
node.preloadState = 'PAUSE' node.preloadState = "PAUSE"
node.preloadAlways = always node.preloadAlways = always
htmx.trigger(node, 'preload:init') htmx.trigger(node, "preload:init")
} }
const parent = event.target || event.detail.elt; const parent = event.target || event.detail.elt;
parent.querySelectorAll("[preload]").forEach(function(node) { parent.querySelectorAll("[preload]").forEach(function(node) {
init(node) init(node)
node.querySelectorAll('a,[hx-get],[data-hx-get]').forEach(init) node.querySelectorAll("a,[hx-get],[data-hx-get]").forEach(init)
}) })
} }
}) })