diff --git a/.build.ps1 b/.build.ps1 index dd0bc0d..df0148d 100644 --- a/.build.ps1 +++ b/.build.ps1 @@ -1,17 +1,17 @@ '# required powershell modules' -Install-Module -Name Pode -Verbose # MIT -Install-Module -Name PSSQLite -Verbose # MIT +Install-Module -Name PSSQLite -MinimumVersion 1.1.0 -MaximumVersion 1.99.99 -Verbose # MIT +Install-Module -Name Pode -MinimumVersion 2.11.0 -MaximumVersion 2.99.99 -Verbose # MIT '# development powershell modules' -Install-Module -Name Pester -Verbose # Apache 2.0 -Install-Module -Name PSScriptAnalyzer -Verbose # MIT +Install-Module -Name Pester -MinimumVersion 5.6.1 -MaximumVersion 5.99.99 -Verbose # Apache 2.0 +Install-Module -Name PSScriptAnalyzer -MinimumVersion 1.23.0 -MaximumVersion 1.99.99 -Verbose # MIT '# required npm packages' npm install htmx.org@next htmx-ext-client-side-templates htmx-ext-debug htmx-ext-json-enc mustache npm install tailwindcss @tailwindcss/forms @tailwindcss/typography @tailwindcss/aspect-ratio '# development npm packages' -npm install -D eslint globals @eslint/js prettier prettier-plugin-tailwindcss prettier-plugin-sql +npm install -D eslint globals htmx-ext-debug @eslint/js prettier prettier-plugin-tailwindcss prettier-plugin-sql '# update packages' npm update @@ -23,9 +23,12 @@ npm run test npm run css '# copy distribution files' -Copy-Item -Path './node_modules/htmx.org/dist/htmx.min.js' -Destination './public/js/htmx.min.js' -Force -Verbose Copy-Item -Path './node_modules/htmx-ext-client-side-templates/client-side-templates.js' -Destination './public/js/client-side-templates.js' -Force -Verbose +Copy-Item -Path './node_modules/htmx-ext-debug/debug.js' -Destination './public/js/debug.js' -Force -Verbose Copy-Item -Path './node_modules/htmx-ext-json-enc/json-enc.js' -Destination './public/js/json-enc.js' -Force -Verbose +Copy-Item -Path './node_modules/htmx.org/dist/htmx.js' -Destination './public/js/htmx.js' -Force -Verbose +Copy-Item -Path './node_modules/htmx.org/dist/htmx.min.js' -Destination './public/js/htmx.min.js' -Force -Verbose +Copy-Item -Path './node_modules/mustache/mustache.js' -Destination './public/js/mustache.js' -Force -Verbose Copy-Item -Path './node_modules/mustache/mustache.min.js' -Destination './public/js/mustache.min.js' -Force -Verbose '# initialize database' diff --git a/api/crud/get.ps1 b/api/crud/get.ps1 index 46a1aa6..3b89b26 100644 --- a/api/crud/get.ps1 +++ b/api/crud/get.ps1 @@ -1,9 +1,11 @@ { $isHTMX = $($WebEvent.Request.Headers.'HX-Request') + $isJsonEnc = $($WebEvent.Request.Headers.'Content-Type') -eq 'application/json' + $db = (Get-PodeConfig).Podex.DBFile - Write-FormattedLog -tag 'api' -log "CRUD API: $($WebEvent.Method.ToUpper()) $($WebEvent.Path) `$isHTMX:$($isHTMX) Q: $($WebEvent.Query | ConvertTo-Json -Compress)" + Write-FormattedLog -tag 'api' -log "Items API: $($WebEvent.Method.ToUpper()) $($WebEvent.Path) `$isHTMX:$($isHTMX) Q: $($WebEvent.Query | ConvertTo-Json -Compress)" try { $tagFilter = $WebEvent.Query['tagFilter'] @@ -43,9 +45,7 @@ # Write-FormattedLog -tag 'database' -log "db: $($db); sqlx: $($sqlx); tagFilter: $tagFilter; search: $search; params: $($params | ConvertTo-Json -Compress)" $rs = (Invoke-SqliteQuery -DataSource $db -Query $sqlx -SqlParameters $params -As PSObject) - - # Write-FormattedLog -tag 'database' -log "db: $($db); countSqlx: $($countSqlx); tagFilter: $tagFilter; search: $search; params: $($params | ConvertTo-Json -Compress)" - $totalItems = (Invoke-SqliteQuery -DataSource $db -Query $countSqlx -SqlParameters $params -As PSObject).count + $totalItems = $rs.Count $tags = (Invoke-SqliteQuery -DataSource $db -Query "select [tag], case when [tag] = '$($tagFilter)' then 'selected' else '' end as [selected] from [tag] order by [tag] asc ;" -As PSObject) @@ -54,7 +54,6 @@ $totalPages = [Math]::Ceiling($totalItems / $pageSize) $hasPreviousPage = $page -gt 1 $hasNextPage = $page -lt $totalPages - $pages = @() for ($i = 1; $i -le $totalPages; $i++) { $pages += @{ @@ -64,7 +63,7 @@ } $response = @{ - features = $rs + rows = $rs tags = $tags startIndex = $startIndex endIndex = $endIndex @@ -77,23 +76,29 @@ currentPage = $page } - # Write-FormattedLog -tag 'debug' -log ($response | ConvertTo-Json -Depth 5 -Compress) -save $true + Write-FormattedLog -tag 'debug' -log ($response | ConvertTo-Json -Depth 5 -Compress) + if ((Get-PodeConfig).Podex.Debug) { + New-Item -Name "$($WebEvent.Method).json" -Path $PSScriptRoot -ItemType File -Value ($response | ConvertTo-Json -Depth 5) -Force + } - if ($isHTMX) { - Write-FormattedLog -tag 'debug' -log "CRUDs found: $($rs.Count)" - $response | ConvertTo-Json -Depth 5 | Write-PodeJsonResponse -StatusCode 200 - } else { - if ($response.features) { - Write-FormattedLog -tag 'debug' -log "CRUDs found: $($rs.Count)" + if ($response.rows) { + Write-FormattedLog -tag 'debug' -log "Items found: $($totalItems)" + if ($isJsonEnc) { $response | ConvertTo-Json -Depth 5 | Write-PodeJsonResponse -StatusCode 200 } else { - Write-FormattedLog -tag 'debug' -log "No features found" - Write-PodeJsonResponse -StatusCode 204 -Value @{ message = "No features found" } + $response | Write-PodeHtmlResponse -StatusCode 200 + } + } else { + Write-FormattedLog -tag 'debug' -log "No items found" + if ($isJsonEnc) { + Write-PodeJsonResponse -StatusCode 204 -Value @{ message = "No items found" } + } else { + Write-PodeHtmlResponse -StatusCode 204 -Value @{ message = "No items found" } } } } catch { - Write-FormattedLog -tag 'error' -log "Error retrieving features: $_" + Write-FormattedLog -tag 'error' -log "Error retrieving items: $($_.Exception.Message)" Write-PodeJsonResponse -StatusCode 500 -Value @{ message = "Internal server error" } } diff --git a/errors/404.html.pode b/errors/404.html.pode index 6a9a85b..540f971 100644 --- a/errors/404.html.pode +++ b/errors/404.html.pode @@ -8,10 +8,11 @@ 404 Not Found - + + - + $(Use-PodePartialView -Path 'partials/header' -Data @{ PageName = '404 Not Found' } ;) diff --git a/errors/default.html.pode b/errors/default.html.pode index d1546a0..bf0a36a 100644 --- a/errors/default.html.pode +++ b/errors/default.html.pode @@ -8,10 +8,11 @@ $($data.status.code ;) Error - + + - + $(Use-PodePartialView -Path 'partials/header' -Data @{ PageName = '$($data.status.code ;) Error' } ;) diff --git a/package.json b/package.json index 98bd113..ba5d599 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "scripts": { "format": "prettier --write .", "lint": "eslint .", - "css": "tailwindcss -c ./tailwind.config.mjs -i ./public/css/tailwind.css -o ./public/css/output.css --minify", + "css": "tailwindcss -c ./tailwind.config.mjs -i ./public/css/tailwind.css -o ./public/css/output.css #--minify", "analyze": "pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -Command \"Import-Module PSScriptAnalyzer ; Invoke-ScriptAnalyzer -Path ./\"", "test": "pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -Command \"Import-Module Pester ; Invoke-Pester -Path ./tests/*.ps1\"", "build": "pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass \"./.build.ps1\"", @@ -27,16 +27,16 @@ "@tailwindcss/forms": "^0.5.9", "@tailwindcss/typography": "^0.5.15", "htmx-ext-client-side-templates": "^2.0.0", - "htmx-ext-debug": "^2.0.0", "htmx-ext-json-enc": "^2.0.1", "htmx.org": "^2.0.3", "mustache": "^4.2.0", - "tailwindcss": "^3.4.13" + "tailwindcss": "^3.4.14" }, "devDependencies": { "@eslint/js": "^9.12.0", "eslint": "^9.12.0", - "globals": "^15.10.0", + "globals": "^15.11.0", + "htmx-ext-debug": "^2.0.0", "prettier": "^3.3.3", "prettier-plugin-sql": "^0.18.1", "prettier-plugin-tailwindcss": "^0.6.8" diff --git a/public/js/debug.js b/public/js/debug.js new file mode 100644 index 0000000..15378cc --- /dev/null +++ b/public/js/debug.js @@ -0,0 +1,11 @@ +htmx.defineExtension('debug', { + onEvent: function(name, evt) { + if (console.debug) { + console.debug(name, evt) + } else if (console) { + console.log('DEBUG:', name, evt) + } else { + throw new Error('NO CONSOLE SUPPORTED') + } + } +}) diff --git a/public/js/htmx.js b/public/js/htmx.js new file mode 100644 index 0000000..f949da0 --- /dev/null +++ b/public/js/htmx.js @@ -0,0 +1,5203 @@ +var htmx = (function() { + 'use strict' + + // Public API + const htmx = { + // Tsc madness here, assigning the functions directly results in an invalid TypeScript output, but reassigning is fine + /* Event processing */ + /** @type {typeof onLoadHelper} */ + onLoad: null, + /** @type {typeof processNode} */ + process: null, + /** @type {typeof addEventListenerImpl} */ + on: null, + /** @type {typeof removeEventListenerImpl} */ + off: null, + /** @type {typeof triggerEvent} */ + trigger: null, + /** @type {typeof ajaxHelper} */ + ajax: null, + /* DOM querying helpers */ + /** @type {typeof find} */ + find: null, + /** @type {typeof findAll} */ + findAll: null, + /** @type {typeof closest} */ + closest: null, + /** + * Returns the input values that would resolve for a given element via the htmx value resolution mechanism + * + * @see https://htmx.org/api/#values + * + * @param {Element} elt the element to resolve values on + * @param {HttpVerb} type the request type (e.g. **get** or **post**) non-GET's will include the enclosing form of the element. Defaults to **post** + * @returns {Object} + */ + values: function(elt, type) { + const inputValues = getInputValues(elt, type || 'post') + return inputValues.values + }, + /* DOM manipulation helpers */ + /** @type {typeof removeElement} */ + remove: null, + /** @type {typeof addClassToElement} */ + addClass: null, + /** @type {typeof removeClassFromElement} */ + removeClass: null, + /** @type {typeof toggleClassOnElement} */ + toggleClass: null, + /** @type {typeof takeClassForElement} */ + takeClass: null, + /** @type {typeof swap} */ + swap: null, + /* Extension entrypoints */ + /** @type {typeof defineExtension} */ + defineExtension: null, + /** @type {typeof removeExtension} */ + removeExtension: null, + /* Debugging */ + /** @type {typeof logAll} */ + logAll: null, + /** @type {typeof logNone} */ + logNone: null, + /* Debugging */ + /** + * The logger htmx uses to log with + * + * @see https://htmx.org/api/#logger + */ + logger: null, + /** + * A property holding the configuration htmx uses at runtime. + * + * Note that using a [meta tag](https://htmx.org/docs/#config) is the preferred mechanism for setting these properties. + * + * @see https://htmx.org/api/#config + */ + config: { + /** + * Whether to use history. + * @type boolean + * @default true + */ + historyEnabled: true, + /** + * The number of pages to keep in **localStorage** for history support. + * @type number + * @default 10 + */ + historyCacheSize: 10, + /** + * @type boolean + * @default false + */ + refreshOnHistoryMiss: false, + /** + * The default swap style to use if **[hx-swap](https://htmx.org/attributes/hx-swap)** is omitted. + * @type HtmxSwapStyle + * @default 'innerHTML' + */ + defaultSwapStyle: 'innerHTML', + /** + * The default delay between receiving a response from the server and doing the swap. + * @type number + * @default 0 + */ + defaultSwapDelay: 0, + /** + * The default delay between completing the content swap and settling attributes. + * @type number + * @default 20 + */ + defaultSettleDelay: 20, + /** + * If true, htmx will inject a small amount of CSS into the page to make indicators invisible unless the **htmx-indicator** class is present. + * @type boolean + * @default true + */ + includeIndicatorStyles: true, + /** + * The class to place on indicators when a request is in flight. + * @type string + * @default 'htmx-indicator' + */ + indicatorClass: 'htmx-indicator', + /** + * The class to place on triggering elements when a request is in flight. + * @type string + * @default 'htmx-request' + */ + requestClass: 'htmx-request', + /** + * The class to temporarily place on elements that htmx has added to the DOM. + * @type string + * @default 'htmx-added' + */ + addedClass: 'htmx-added', + /** + * The class to place on target elements when htmx is in the settling phase. + * @type string + * @default 'htmx-settling' + */ + settlingClass: 'htmx-settling', + /** + * The class to place on target elements when htmx is in the swapping phase. + * @type string + * @default 'htmx-swapping' + */ + swappingClass: 'htmx-swapping', + /** + * Allows the use of eval-like functionality in htmx, to enable **hx-vars**, trigger conditions & script tag evaluation. Can be set to **false** for CSP compatibility. + * @type boolean + * @default true + */ + allowEval: true, + /** + * If set to false, disables the interpretation of script tags. + * @type boolean + * @default true + */ + allowScriptTags: true, + /** + * If set, the nonce will be added to inline scripts. + * @type string + * @default '' + */ + inlineScriptNonce: '', + /** + * If set, the nonce will be added to inline styles. + * @type string + * @default '' + */ + inlineStyleNonce: '', + /** + * The attributes to settle during the settling phase. + * @type string[] + * @default ['class', 'style', 'width', 'height'] + */ + attributesToSettle: ['class', 'style', 'width', 'height'], + /** + * Allow cross-site Access-Control requests using credentials such as cookies, authorization headers or TLS client certificates. + * @type boolean + * @default false + */ + withCredentials: false, + /** + * @type number + * @default 0 + */ + timeout: 0, + /** + * The default implementation of **getWebSocketReconnectDelay** for reconnecting after unexpected connection loss by the event code **Abnormal Closure**, **Service Restart** or **Try Again Later**. + * @type {'full-jitter' | ((retryCount:number) => number)} + * @default "full-jitter" + */ + wsReconnectDelay: 'full-jitter', + /** + * The type of binary data being received over the WebSocket connection + * @type BinaryType + * @default 'blob' + */ + wsBinaryType: 'blob', + /** + * @type string + * @default '[hx-disable], [data-hx-disable]' + */ + disableSelector: '[hx-disable], [data-hx-disable]', + /** + * @type {'auto' | 'instant' | 'smooth'} + * @default 'instant' + */ + scrollBehavior: 'instant', + /** + * If the focused element should be scrolled into view. + * @type boolean + * @default false + */ + defaultFocusScroll: false, + /** + * If set to true htmx will include a cache-busting parameter in GET requests to avoid caching partial responses by the browser + * @type boolean + * @default false + */ + getCacheBusterParam: false, + /** + * If set to true, htmx will use the View Transition API when swapping in new content. + * @type boolean + * @default false + */ + globalViewTransitions: false, + /** + * htmx will format requests with these methods by encoding their parameters in the URL, not the request body + * @type {(HttpVerb)[]} + * @default ['get', 'delete'] + */ + methodsThatUseUrlParams: ['get', 'delete'], + /** + * If set to true, disables htmx-based requests to non-origin hosts. + * @type boolean + * @default false + */ + selfRequestsOnly: true, + /** + * If set to true htmx will not update the title of the document when a title tag is found in new content + * @type boolean + * @default false + */ + ignoreTitle: false, + /** + * Whether the target of a boosted element is scrolled into the viewport. + * @type boolean + * @default true + */ + scrollIntoViewOnBoost: true, + /** + * The cache to store evaluated trigger specifications into. + * You may define a simple object to use a never-clearing cache, or implement your own system using a [proxy object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Proxy) + * @type {Object|null} + * @default null + */ + triggerSpecsCache: null, + /** @type boolean */ + disableInheritance: false, + /** @type HtmxResponseHandlingConfig[] */ + responseHandling: [ + { code: '204', swap: false }, + { code: '[23]..', swap: true }, + { code: '[45]..', swap: false, error: true } + ], + /** + * Whether to process OOB swaps on elements that are nested within the main response element. + * @type boolean + * @default true + */ + allowNestedOobSwaps: true + }, + /** @type {typeof parseInterval} */ + parseInterval: null, + /** @type {typeof internalEval} */ + _: null, + version: '2.0.3' + } + // Tsc madness part 2 + htmx.onLoad = onLoadHelper + htmx.process = processNode + htmx.on = addEventListenerImpl + htmx.off = removeEventListenerImpl + htmx.trigger = triggerEvent + htmx.ajax = ajaxHelper + htmx.find = find + htmx.findAll = findAll + htmx.closest = closest + htmx.remove = removeElement + htmx.addClass = addClassToElement + htmx.removeClass = removeClassFromElement + htmx.toggleClass = toggleClassOnElement + htmx.takeClass = takeClassForElement + htmx.swap = swap + htmx.defineExtension = defineExtension + htmx.removeExtension = removeExtension + htmx.logAll = logAll + htmx.logNone = logNone + htmx.parseInterval = parseInterval + htmx._ = internalEval + + const internalAPI = { + addTriggerHandler, + bodyContains, + canAccessLocalStorage, + findThisElement, + filterValues, + swap, + hasAttribute, + getAttributeValue, + getClosestAttributeValue, + getClosestMatch, + getExpressionVars, + getHeaders, + getInputValues, + getInternalData, + getSwapSpecification, + getTriggerSpecs, + getTarget, + makeFragment, + mergeObjects, + makeSettleInfo, + oobSwap, + querySelectorExt, + settleImmediately, + shouldCancel, + triggerEvent, + triggerErrorEvent, + withExtensions + } + + const VERBS = ['get', 'post', 'put', 'delete', 'patch'] + const VERB_SELECTOR = VERBS.map(function(verb) { + return '[hx-' + verb + '], [data-hx-' + verb + ']' + }).join(', ') + + //= =================================================================== + // Utilities + //= =================================================================== + + /** + * Parses an interval string consistent with the way htmx does. Useful for plugins that have timing-related attributes. + * + * Caution: Accepts an int followed by either **s** or **ms**. All other values use **parseFloat** + * + * @see https://htmx.org/api/#parseInterval + * + * @param {string} str timing string + * @returns {number|undefined} + */ + function parseInterval(str) { + if (str == undefined) { + return undefined + } + + let interval = NaN + if (str.slice(-2) == 'ms') { + interval = parseFloat(str.slice(0, -2)) + } else if (str.slice(-1) == 's') { + interval = parseFloat(str.slice(0, -1)) * 1000 + } else if (str.slice(-1) == 'm') { + interval = parseFloat(str.slice(0, -1)) * 1000 * 60 + } else { + interval = parseFloat(str) + } + return isNaN(interval) ? undefined : interval + } + + /** + * @param {Node} elt + * @param {string} name + * @returns {(string | null)} + */ + function getRawAttribute(elt, name) { + return elt instanceof Element && elt.getAttribute(name) + } + + /** + * @param {Element} elt + * @param {string} qualifiedName + * @returns {boolean} + */ + // resolve with both hx and data-hx prefixes + function hasAttribute(elt, qualifiedName) { + return !!elt.hasAttribute && (elt.hasAttribute(qualifiedName) || + elt.hasAttribute('data-' + qualifiedName)) + } + + /** + * + * @param {Node} elt + * @param {string} qualifiedName + * @returns {(string | null)} + */ + function getAttributeValue(elt, qualifiedName) { + return getRawAttribute(elt, qualifiedName) || getRawAttribute(elt, 'data-' + qualifiedName) + } + + /** + * @param {Node} elt + * @returns {Node | null} + */ + function parentElt(elt) { + const parent = elt.parentElement + if (!parent && elt.parentNode instanceof ShadowRoot) return elt.parentNode + return parent + } + + /** + * @returns {Document} + */ + function getDocument() { + return document + } + + /** + * @param {Node} elt + * @param {boolean} global + * @returns {Node|Document} + */ + function getRootNode(elt, global) { + return elt.getRootNode ? elt.getRootNode({ composed: global }) : getDocument() + } + + /** + * @param {Node} elt + * @param {(e:Node) => boolean} condition + * @returns {Node | null} + */ + function getClosestMatch(elt, condition) { + while (elt && !condition(elt)) { + elt = parentElt(elt) + } + + return elt || null + } + + /** + * @param {Element} initialElement + * @param {Element} ancestor + * @param {string} attributeName + * @returns {string|null} + */ + function getAttributeValueWithDisinheritance(initialElement, ancestor, attributeName) { + const attributeValue = getAttributeValue(ancestor, attributeName) + const disinherit = getAttributeValue(ancestor, 'hx-disinherit') + var inherit = getAttributeValue(ancestor, 'hx-inherit') + if (initialElement !== ancestor) { + if (htmx.config.disableInheritance) { + if (inherit && (inherit === '*' || inherit.split(' ').indexOf(attributeName) >= 0)) { + return attributeValue + } else { + return null + } + } + if (disinherit && (disinherit === '*' || disinherit.split(' ').indexOf(attributeName) >= 0)) { + return 'unset' + } + } + return attributeValue + } + + /** + * @param {Element} elt + * @param {string} attributeName + * @returns {string | null} + */ + function getClosestAttributeValue(elt, attributeName) { + let closestAttr = null + getClosestMatch(elt, function(e) { + return !!(closestAttr = getAttributeValueWithDisinheritance(elt, asElement(e), attributeName)) + }) + if (closestAttr !== 'unset') { + return closestAttr + } + } + + /** + * @param {Node} elt + * @param {string} selector + * @returns {boolean} + */ + function matches(elt, selector) { + // @ts-ignore: non-standard properties for browser compatibility + // noinspection JSUnresolvedVariable + const matchesFunction = elt instanceof Element && (elt.matches || elt.matchesSelector || elt.msMatchesSelector || elt.mozMatchesSelector || elt.webkitMatchesSelector || elt.oMatchesSelector) + return !!matchesFunction && matchesFunction.call(elt, selector) + } + + /** + * @param {string} str + * @returns {string} + */ + function getStartTag(str) { + const tagMatcher = /<([a-z][^\/\0>\x20\t\r\n\f]*)/i + const match = tagMatcher.exec(str) + if (match) { + return match[1].toLowerCase() + } else { + return '' + } + } + + /** + * @param {string} resp + * @returns {Document} + */ + function parseHTML(resp) { + const parser = new DOMParser() + return parser.parseFromString(resp, 'text/html') + } + + /** + * @param {DocumentFragment} fragment + * @param {Node} elt + */ + function takeChildrenFor(fragment, elt) { + while (elt.childNodes.length > 0) { + fragment.append(elt.childNodes[0]) + } + } + + /** + * @param {HTMLScriptElement} script + * @returns {HTMLScriptElement} + */ + function duplicateScript(script) { + const newScript = getDocument().createElement('script') + forEach(script.attributes, function(attr) { + newScript.setAttribute(attr.name, attr.value) + }) + newScript.textContent = script.textContent + newScript.async = false + if (htmx.config.inlineScriptNonce) { + newScript.nonce = htmx.config.inlineScriptNonce + } + return newScript + } + + /** + * @param {HTMLScriptElement} script + * @returns {boolean} + */ + function isJavaScriptScriptNode(script) { + return script.matches('script') && (script.type === 'text/javascript' || script.type === 'module' || script.type === '') + } + + /** + * we have to make new copies of script tags that we are going to insert because + * SOME browsers (not saying who, but it involves an element and an animal) don't + * execute scripts created in