kempo 4.2.26 → 4.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ import { dirname, join } from 'path';
2
+ import { fileURLToPath } from 'url';
3
+ import { renderExternalPage } from 'kempo-server/templating';
4
+
5
+ /*
6
+ The last stop for any request nothing else on the site claimed.
7
+
8
+ kempo-server walks upward from the deepest directory a URL implies looking for a CATCH file, so
9
+ this one — sitting at the site root — is reached for every unmatched URL, at any depth, with no
10
+ prefix or configuration involved. It fires the `route:unmatched` hook, which is how an extension
11
+ answers a request for something that has no file behind it (a gated download, a short link, a
12
+ generated feed), and otherwise renders CATCH.page.html as the site's 404.
13
+
14
+ This file takes priority over CATCH.page.html, which is kept as the page shown when no handler
15
+ claims the request — so the 404 page stays editable as an ordinary page.
16
+
17
+ Everything real lives in kempo so it can be fixed by upgrading rather than by editing this file.
18
+ */
19
+
20
+ const PUBLIC_DIR = dirname(fileURLToPath(import.meta.url));
21
+
22
+ /*
23
+ Resolved once, at load, and deliberately outside the request handler: a failure to *find* kempo
24
+ falls back to serving the 404 page directly, while a failure *inside* the dispatcher still
25
+ propagates as a normal error rather than being swallowed and double-answered.
26
+
27
+ The fallback matters because this file owns every 404 on the site. If it could throw, a site
28
+ whose install is halfway through an upgrade would answer 500 for every unmatched URL instead of
29
+ showing its own 404 page — a much worse failure than losing the hook.
30
+ */
31
+ let serveUnmatched = null;
32
+ try {
33
+ ({ default: serveUnmatched } = await import('kempo/server/utils/routing/serveUnmatched.js'));
34
+ } catch {
35
+ console.warn('[kempo] Could not load the route:unmatched dispatcher; serving the 404 page directly. Is kempo installed?');
36
+ }
37
+
38
+ export default async (request, response) => {
39
+ if(serveUnmatched) return serveUnmatched(request, response, PUBLIC_DIR);
40
+
41
+ const url = request.url.split('?')[0];
42
+ let resolveDir = PUBLIC_DIR;
43
+ for(const part of url.replace(/\/+$/, '').split('/').filter(Boolean)) resolveDir = join(resolveDir, part);
44
+
45
+ try {
46
+ const html = await renderExternalPage(join(PUBLIC_DIR, 'CATCH.page.html'), PUBLIC_DIR, resolveDir);
47
+ response.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
48
+ response.end(html);
49
+ } catch {
50
+ response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
51
+ response.end('Not Found');
52
+ }
53
+ };
@@ -27,7 +27,7 @@ import e from"/kempo-ui/components/ShadowComponent.js";import{html as t,repeat a
27
27
  <p class="mt-sm muted"><small id="dlg-move-preview"></small></p>
28
28
  </div>
29
29
  </div>
30
- `,{title:"Move Page",confirmText:"Save and Move Page",confirmAction:async()=>{const e=u.querySelector("#dlg-move-dir").value.trim()||"/",t=p(u.querySelector("#dlg-move-name").value.trim());if(!t)return void d.error("Page name is required");if(!m(e))return void d.error("Invalid directory path");const a=g(e),n="."===a?`${t}.page.html`:`${a}/${t}.page.html`;this.saving=!0;const[i]=await s({file:this.file,...this.getFormState()});if(this.saving=!1,i)return void d.error(i.msg||"Failed to save page");const[l,o]=await r({file:this.file,newFile:n});l?d.error(l.msg||"Failed to move page"):(d.success("Page saved and moved"),setTimeout(()=>{window.location.href=`/admin/pages/edit?path=${encodeURIComponent(o.file.replace(/\.page\.html$/,""))}`},1e3))},cancelText:"Cancel"}),v=()=>{const e=g(u.querySelector("#dlg-move-dir").value||"/"),t=p(u.querySelector("#dlg-move-name").value),a="."===e?"/":"/"+e+"/";u.querySelector("#dlg-move-preview").textContent=t?a+t:""};u.querySelector("#dlg-move-dir").addEventListener("input",e=>{u.querySelector("#dlg-move-dir-error").classList.toggle("d-n",m(e.target.value||"/")),v()}),u.querySelector("#dlg-move-name").addEventListener("input",v),v()};handleReset=()=>{c.confirm("Reset this page? Any unsaved changes will be lost.",e=>{e&&(this.contents=this.buildContents(this.page.template,this.page.contents),this.page={...this.page})})};handleSave=async()=>{this.saving=!0;const e=this.getFormState(),[t,a]=await s({file:this.file,...e});this.saving=!1,t?d.error(t.msg||"Failed to save page"):(this.contents=this.contents.map(t=>{const a=e.contents.find(e=>e.location===t.location);return a?{...t,content:a.content}:t}),this.page={...this.page,...e,contents:e.contents.filter(e=>e.content.trim()),updatedAt:a.updatedAt},document.title=`Edit: ${e.name||e.title||this.file} - Admin`,d.success("Page saved"))};render(){if(this.loading)return t`<div>Loading page...</div>`;if(this.error)return t`
30
+ `,{title:"Move Page",confirmText:"Save and Move Page",confirmAction:async()=>{const e=u.querySelector("#dlg-move-dir").value.trim()||"/",t=p(u.querySelector("#dlg-move-name").value.trim());if(!t)return void d.error("Page name is required");if(!m(e))return void d.error("Invalid directory path");const a=g(e),n="."===a?`${t}.page.html`:`${a}/${t}.page.html`;this.saving=!0;const[i]=await s({file:this.file,...this.getFormState()});if(this.saving=!1,i)return void d.error(i.msg||"Failed to save page");const[l,o]=await r({file:this.file,newFile:n});l?d.error(l.msg||"Failed to move page"):(d.success("Page saved and moved"),setTimeout(()=>{window.location.href=`/admin/content/pages/edit?path=${encodeURIComponent(o.file.replace(/\.page\.html$/,""))}`},1e3))},cancelText:"Cancel"}),v=()=>{const e=g(u.querySelector("#dlg-move-dir").value||"/"),t=p(u.querySelector("#dlg-move-name").value),a="."===e?"/":"/"+e+"/";u.querySelector("#dlg-move-preview").textContent=t?a+t:""};u.querySelector("#dlg-move-dir").addEventListener("input",e=>{u.querySelector("#dlg-move-dir-error").classList.toggle("d-n",m(e.target.value||"/")),v()}),u.querySelector("#dlg-move-name").addEventListener("input",v),v()};handleReset=()=>{c.confirm("Reset this page? Any unsaved changes will be lost.",e=>{e&&(this.contents=this.buildContents(this.page.template,this.page.contents),this.page={...this.page})})};handleSave=async()=>{this.saving=!0;const e=this.getFormState(),[t,a]=await s({file:this.file,...e});this.saving=!1,t?d.error(t.msg||"Failed to save page"):(this.contents=this.contents.map(t=>{const a=e.contents.find(e=>e.location===t.location);return a?{...t,content:a.content}:t}),this.page={...this.page,...e,contents:e.contents.filter(e=>e.content.trim()),updatedAt:a.updatedAt},document.title=`Edit: ${e.name||e.title||this.file} - Admin`,d.success("Page saved"))};render(){if(this.loading)return t`<div>Loading page...</div>`;if(this.error)return t`
31
31
  <div>
32
32
  <p>Page not found.</p>
33
33
  <a href="/admin/pages">Back to Pages</a>
@@ -1 +1 @@
1
- {"version":3,"file":"src\\admin\\components\\PageEditor.js","names":["ShadowComponent","html","repeat","getPage","updatePage","listTemplates","deletePages","listDirectories","movePage","Dialog","Toast","CODE_MODE_LOCATIONS","Set","slugify","name","toLowerCase","trim","replace","PageEditor","static","loading","state","error","saving","page","templates","contents","constructor","super","this","file","connectedCallback","document","addEventListener","handleKeyDown","URLSearchParams","window","location","search","get","err","tmplData","Promise","all","buildContents","template","title","disconnectedCallback","removeEventListener","getPageUrl","split","filter","Boolean","join","templateName","existingContents","tmpl","find","t","locations","label","map","l","result","block","b","content","orphaned","some","push","getFormState","root","shadowRoot","editorMap","Map","querySelectorAll","ed","dataset","getValue","querySelector","value","description","e","ctrlKey","metaKey","key","preventDefault","locked","handleSave","handleTemplateChange","newTemplate","target","currentContents","handleDelete","confirm","async","confirmed","msg","success","setTimeout","href","handleMove","dirData","directories","parts","currentName","pop","currentDir","isValidDir","p","test","includes","dirToInternal","currentDirDisplay","$dialog","create","d","alert","confirmText","confirmAction","dirInput","newName","newDir","newFile","saveError","moveError","moved","encodeURIComponent","cancelText","updatePreview","dir","prefix","textContent","classList","toggle","handleReset","saved","c","current","x","updatedAt","render","isSystem","owner","isLocked","author","pageDir","slice","reachable","tDir","directory","pDir","startsWith","currentFound","templateLabel","createdAt","Date","toLocaleString","Object","keys","extraMetadata","length","entries","isDefault","headerTitle","toUpperCase","has","customElements","define"],"sources":["0"],"sourcesContent":["import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';\nimport { html, repeat } from '/kempo-ui/lit-all.min.js';\nimport { getPage, updatePage, listTemplates, deletePages, listDirectories, movePage } from '/kempo/sdk.js';\nimport Dialog from '/kempo-ui/components/Dialog.js';\nimport Toast from '/kempo-ui/components/Toast.js';\nimport '/kempo-ui/components/Icon.js';\nimport '/kempo-ui/components/Accordion.js';\nimport '/kempo-ui/components/HtmlEditor.js';\n\nconst CODE_MODE_LOCATIONS = new Set(['head', 'scripts']);\n\nconst slugify = name => name\n .toLowerCase()\n .trim()\n .replace(/[^\\w\\s-]/g, '')\n .replace(/[\\s_]+/g, '-')\n .replace(/-+/g, '-')\n .replace(/^-|-$/g, '');\n\nexport default class PageEditor extends ShadowComponent {\n static properties = {\n loading: { state: true },\n error: { state: true },\n saving: { state: true },\n page: { state: true },\n templates: { state: true },\n contents: { state: true }\n };\n\n constructor() {\n super();\n this.loading = true;\n this.error = false;\n this.saving = false;\n this.page = null;\n this.templates = [];\n this.contents = [];\n this.file = '';\n }\n\n async connectedCallback() {\n super.connectedCallback();\n document.addEventListener('keydown', this.handleKeyDown);\n this.file = new URLSearchParams(window.location.search).get('path') + '.page.html';\n\n if (!this.file || this.file === '.page.html') {\n this.loading = false;\n this.error = true;\n return;\n }\n\n const [[err, page], [, tmplData]] = await Promise.all([\n getPage(this.file),\n listTemplates()\n ]);\n\n this.loading = false;\n\n if (err) {\n this.error = true;\n return;\n }\n\n this.templates = tmplData?.templates || [];\n this.page = page;\n this.contents = this.buildContents(page.template, page.contents);\n document.title = `Edit: ${page.name || page.title || this.file} - Admin`;\n }\n\n disconnectedCallback() {\n super.disconnectedCallback();\n document.removeEventListener('keydown', this.handleKeyDown);\n }\n\n /*\n Helpers\n */\n\n getPageUrl() {\n const path = this.file.replace(/\\.page\\.html$/, '');\n return '/' + path.split('/').filter(Boolean).join('/');\n }\n\n buildContents(templateName, existingContents) {\n const tmpl = this.templates.find(t => t.name === templateName);\n const raw = tmpl?.locations || [{ name: 'default', label: null }];\n const locations = raw.map(l => typeof l === 'string' ? { name: l, label: null } : l);\n const result = locations.map(({ name, label }) => {\n const block = existingContents.find(b => b.location === name);\n return { location: name, label: label || null, content: block?.content || '', orphaned: false };\n });\n for (const block of existingContents) {\n if (!locations.some(l => l.name === block.location))\n result.push({ location: block.location, label: null, content: block.content, orphaned: true });\n }\n return result;\n }\n\n getFormState() {\n const root = this.shadowRoot;\n const editorMap = new Map(\n [...root.querySelectorAll('k-html-editor[data-location]')].map(ed => [ed.dataset.location, ed.getValue()])\n );\n return {\n name: root.querySelector('#metaName').value,\n title: root.querySelector('#metaTitle').value,\n description: root.querySelector('#metaDescription').value,\n template: root.querySelector('#metaTemplate').value,\n contents: this.contents.map(({ location, content, orphaned }) => ({\n location,\n content: orphaned\n ? (editorMap.get(location)?.trim() || content)\n : (editorMap.get(location) ?? content)\n }))\n };\n }\n\n /*\n Event Handlers\n */\n\n handleKeyDown = e => {\n if((e.ctrlKey || e.metaKey) && e.key === 's'){\n e.preventDefault();\n if(!this.saving && !this.page?.locked) this.handleSave();\n }\n };\n\n handleTemplateChange = e => {\n if(this.page?.locked) return;\n const newTemplate = e.target.value;\n const editorMap = new Map(\n [...this.shadowRoot.querySelectorAll('k-html-editor[data-location]')].map(ed => [ed.dataset.location, ed.getValue()])\n );\n // Fall back to state content for any editor that hasn't initialized (Monaco lazy-loads)\n const currentContents = this.contents.map(({ location, content }) => ({\n location,\n content: editorMap.get(location)?.trim() || content\n }));\n this.contents = this.buildContents(newTemplate, currentContents);\n this.page = { ...this.page, template: newTemplate };\n };\n\n handleDelete = () => {\n Dialog.confirm('Delete this page? This action cannot be undone.', async confirmed => {\n if (!confirmed) return;\n const [error] = await deletePages([this.file]);\n if (error) {\n Toast.error(error.msg || 'Failed to delete page');\n return;\n }\n Toast.success('Page deleted');\n setTimeout(() => { window.location.href = '/admin/pages'; }, 1000);\n });\n };\n\n handleMove = async () => {\n const [, dirData] = await listDirectories();\n const directories = dirData?.directories || ['.'];\n\n const parts = this.file.replace(/\\.page\\.html$/, '').split('/');\n const currentName = parts.pop();\n const currentDir = parts.join('/') || '.';\n\n const isValidDir = p => /^\\/[a-zA-Z0-9_\\-\\[\\]\\/]*$/.test(p) && !p.includes('..') && !p.includes('//');\n const dirToInternal = p => { const t = (p || '/').replace(/^\\//, '').replace(/\\/$/, ''); return t || '.'; };\n const currentDirDisplay = currentDir === '.' ? '/' : '/' + currentDir;\n\n const openLearnMore = () => Dialog.alert(html`\n <div class=\"p\">\n <p>Directory paths define where a page lives in the URL structure.</p>\n <p><strong>Examples:</strong></p>\n <ul>\n <li><code>/</code> — root, e.g. <code>/about</code></li>\n <li><code>/blog</code> — e.g. <code>/blog/my-post</code></li>\n <li><code>/blog/[slug]</code> — dynamic segment; <code>[slug]</code> matches any value</li>\n </ul>\n <p>Dynamic segments like <code>[token]</code> allow a single page to handle many URLs. The segment name is available to the page as a URL parameter.</p>\n <p>You can nest segments: <code>/store/[category]/[id]</code></p>\n </div>\n `, null, { title: 'About Directory Paths' });\n\n const $dialog = Dialog.create(html`\n <div class=\"p\">\n <div class=\"mb\">\n <label class=\"d-b mb-sm\"><strong>Location</strong></label>\n <input type=\"text\" id=\"dlg-move-dir\" class=\"full\" list=\"dlg-move-dir-list\" .value=\"${currentDirDisplay}\" placeholder=\"/\">\n <datalist id=\"dlg-move-dir-list\">\n ${directories.map(d => html`<option value=\"${d === '.' ? '/' : '/' + d}\">`)} \n </datalist>\n <p id=\"dlg-move-dir-error\" class=\"mt-sm tc-danger d-n\"><small>Invalid path — use format: /path/to/directory</small></p>\n <p class=\"mt-sm muted\"><small>Supports dynamic segments e.g. <code>/blog/[slug]</code>. <button class=\"link\" @click=\"${openLearnMore}\">Learn more</button></small></p>\n </div>\n <div>\n <label class=\"d-b mb-sm\"><strong>Page Name</strong></label>\n <input type=\"text\" id=\"dlg-move-name\" class=\"full\" .value=\"${currentName}\">\n <p class=\"mt-sm muted\"><small id=\"dlg-move-preview\"></small></p>\n </div>\n </div>\n `, {\n title: 'Move Page',\n confirmText: 'Save and Move Page',\n confirmAction: async () => {\n const dirInput = $dialog.querySelector('#dlg-move-dir').value.trim() || '/';\n const newName = slugify($dialog.querySelector('#dlg-move-name').value.trim());\n if (!newName) {\n Toast.error('Page name is required');\n return;\n }\n if (!isValidDir(dirInput)) {\n Toast.error('Invalid directory path');\n return;\n }\n const newDir = dirToInternal(dirInput);\n const newFile = newDir === '.'\n ? `${newName}.page.html`\n : `${newDir}/${newName}.page.html`;\n\n this.saving = true;\n const [saveError] = await updatePage({ file: this.file, ...this.getFormState() });\n this.saving = false;\n if (saveError) {\n Toast.error(saveError.msg || 'Failed to save page');\n return;\n }\n\n const [moveError, moved] = await movePage({ file: this.file, newFile });\n if (moveError) {\n Toast.error(moveError.msg || 'Failed to move page');\n return;\n }\n\n Toast.success('Page saved and moved');\n setTimeout(() => {\n window.location.href = `/admin/pages/edit?path=${encodeURIComponent(moved.file.replace(/\\.page\\.html$/, ''))}`;\n }, 1000);\n },\n cancelText: 'Cancel'\n });\n\n const updatePreview = () => {\n const dir = dirToInternal($dialog.querySelector('#dlg-move-dir').value || '/');\n const name = slugify($dialog.querySelector('#dlg-move-name').value);\n const prefix = dir === '.' ? '/' : '/' + dir + '/';\n $dialog.querySelector('#dlg-move-preview').textContent = name ? prefix + name : '';\n };\n $dialog.querySelector('#dlg-move-dir').addEventListener('input', e => {\n $dialog.querySelector('#dlg-move-dir-error').classList.toggle('d-n', isValidDir(e.target.value || '/'));\n updatePreview();\n });\n $dialog.querySelector('#dlg-move-name').addEventListener('input', updatePreview);\n updatePreview();\n };\n\n handleReset = () => {\n Dialog.confirm('Reset this page? Any unsaved changes will be lost.', confirmed => {\n if (!confirmed) return;\n this.contents = this.buildContents(this.page.template, this.page.contents);\n this.page = { ...this.page };\n });\n };\n\n handleSave = async () => {\n this.saving = true;\n const state = this.getFormState();\n const [saveError, saved] = await updatePage({ file: this.file, ...state });\n this.saving = false;\n\n if (saveError) {\n Toast.error(saveError.msg || 'Failed to save page');\n return;\n }\n\n this.contents = this.contents.map(c => {\n const current = state.contents.find(x => x.location === c.location);\n return current ? { ...c, content: current.content } : c;\n });\n this.page = {\n ...this.page,\n ...state,\n contents: state.contents.filter(b => b.content.trim()),\n updatedAt: saved.updatedAt\n };\n\n document.title = `Edit: ${state.name || state.title || this.file} - Admin`;\n Toast.success('Page saved');\n };\n\n /*\n Render\n */\n\n render() {\n if (this.loading) return html`<div>Loading page...</div>`;\n\n if (this.error) return html`\n <div>\n <p>Page not found.</p>\n <a href=\"/admin/pages\">Back to Pages</a>\n </div>\n `;\n\n const { page, templates, contents } = this;\n const isSystem = page.owner === 'system';\n const isLocked = page.locked;\n\n return html`\n ${isLocked ? html`\n <div class=\"p r mb bc-warning tc-warning\">\n <k-icon name=\"lock\"></k-icon> This page is locked and cannot be edited. ${page.owner === 'custom' ? 'Locked by developer' : `Managed by: ${page.owner || 'external system'}`}\n </div>\n ` : ''}\n <div class=\"d-f mb\">\n <div class=\"btn-grp mrh mb\">\n <a href=\"/admin/content/pages\" class=\"btn\">\n <k-icon name=\"arrow\" direction=\"left\"></k-icon> Pages\n </a>\n <a href=\"${this.getPageUrl()}\" id=\"page-link\" class=\"btn\">\n <k-icon name=\"visibility\"></k-icon> View Page\n </a>\n </div>\n \n <div class=\"flex\"></div>\n ${!isSystem && !isLocked ? html`\n <div class=\"btn-grp mrh mb\">\n <button class=\"danger\" @click=\"${this.handleDelete}\"><k-icon name=\"delete\"></k-icon> Delete</button>\n <button @click=\"${this.handleMove}\"><k-icon name=\"drive_file_move\"></k-icon> Move Page</button>\n </div>\n ` : ''}\n ${!isLocked ? html`\n <div class=\"btn-grp mrh mb\">\n <button @click=\"${this.handleReset}\"><k-icon name=\"restart_alt\"></k-icon> Reset</button>\n <button id=\"saveBtn\" class=\"primary\" ?disabled=\"${this.saving}\" @click=\"${this.handleSave}\">\n <k-icon name=\"save\"></k-icon> Save\n </button>\n </div>\n ` : ''}\n </div>\n <h1 class=\"mb0\">Page: ${page.name || page.title || this.file}</h1>\n\n <a href=\"${this.getPageUrl()}\" class=\"d-b mb\"><code>${this.getPageUrl()}</code></a>\n\n <k-accordion>\n <k-accordion-header for-panel=\"metadata\">Metadata</k-accordion-header>\n <k-accordion-panel name=\"metadata\">\n <div class=\"p\">\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Name</strong></label>\n <input type=\"text\" id=\"metaName\" class=\"flex\" .value=\"${page.name || ''}\" ?disabled=\"${isSystem || isLocked}\">\n </div>\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Title</strong></label>\n <input type=\"text\" id=\"metaTitle\" class=\"flex\" .value=\"${page.title || ''}\" ?disabled=\"${isLocked}\">\n </div>\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Description</strong></label>\n <input type=\"text\" id=\"metaDescription\" class=\"flex\" .value=\"${page.description || ''}\" ?disabled=\"${isSystem || isLocked}\">\n </div>\n ${!isSystem ? html`\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Author</strong></label>\n <span class=\"muted\">${page.author || ''}</span>\n </div>\n ` : ''}\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Template</strong></label>\n ${(() => {\n const pageDir = this.file.replace(/\\.page\\.html$/, '').split('/').slice(0, -1).join('/') || '.';\n const reachable = templates.filter(t => {\n const tDir = t.directory === '.' ? '' : t.directory;\n const pDir = pageDir === '.' ? '' : pageDir;\n return tDir === '' || pDir === tDir || pDir.startsWith(tDir + '/');\n });\n const templateLabel = t => t.directory === '.' ? `/${t.name}` : `/${t.directory}/${t.name}`;\n const currentFound = reachable.some(t => t.name === page.template);\n return html`\n <select id=\"metaTemplate\" class=\"flex\" @change=\"${this.handleTemplateChange}\" ?disabled=\"${isLocked}\">\n ${!currentFound ? html`<option value=\"${page.template}\" selected>${page.template} (Not Found, using default)</option>` : ''}\n ${reachable.map(t => html`\n <option value=\"${t.name}\" ?selected=\"${t.name === page.template}\">${templateLabel(t)}</option>\n `)}\n </select>\n `;\n })()}\n </div>\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Owner</strong></label>\n <span class=\"muted\">${page.owner || ''}</span>\n </div>\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Created</strong></label>\n <span class=\"muted\">${page.createdAt ? new Date(page.createdAt).toLocaleString() : ''}</span>\n </div>\n <div class=\"d-f\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Updated</strong></label>\n <span class=\"muted\">${page.updatedAt ? new Date(page.updatedAt).toLocaleString() : ''}</span>\n </div>\n </div>\n </k-accordion-panel>\n ${Object.keys(page.extraMetadata || {}).length ? html`\n <k-accordion-header for-panel=\"extra-metadata\">Extra Metadata</k-accordion-header>\n <k-accordion-panel name=\"extra-metadata\">\n <div class=\"p\">\n ${Object.entries(page.extraMetadata).map(([key, value]) => html`\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\" class=\"muted\"><strong>${key}</strong></label>\n <span>${value}</span>\n </div>\n `)}\n <p class=\"muted mt0\"><small>Extra metadata is managed by extensions and cannot be edited here.</small></p>\n </div>\n </k-accordion-panel>\n ` : ''}\n ${repeat(contents, c => c.location, ({ location, label, content, orphaned }) => {\n const isDefault = location === 'default';\n const headerTitle = label || (isDefault ? 'Content' : location.slice(0, 1).toUpperCase() + location.slice(1));\n return html`\n <k-accordion-header for-panel=\"content-${location}\" ?active=\"${isDefault}\">\n ${headerTitle}${orphaned ? html` <span class=\"tc-warning\"><small>(not in template)</small></span>` : ''}\n </k-accordion-header>\n <k-accordion-panel name=\"content-${location}\" ?active=\"${isDefault}\">\n <div class=\"p\">\n <k-html-editor\n class=\"r b\"\n data-location=\"${location}\"\n controls=\"full\"\n .value=\"${content}\"\n mode=\"${CODE_MODE_LOCATIONS.has(location) ? 'code' : 'visual'}\"\n style=\"height: 400px;\"\n ?disabled=\"${isLocked}\"\n ></k-html-editor>\n </div>\n </k-accordion-panel>\n `;\n })}\n </k-accordion>\n `;\n }\n}\n\ncustomElements.define('admin-page-editor', PageEditor);\n"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"file":"src\\admin\\components\\PageEditor.js","names":["ShadowComponent","html","repeat","getPage","updatePage","listTemplates","deletePages","listDirectories","movePage","Dialog","Toast","CODE_MODE_LOCATIONS","Set","slugify","name","toLowerCase","trim","replace","PageEditor","static","loading","state","error","saving","page","templates","contents","constructor","super","this","file","connectedCallback","document","addEventListener","handleKeyDown","URLSearchParams","window","location","search","get","err","tmplData","Promise","all","buildContents","template","title","disconnectedCallback","removeEventListener","getPageUrl","split","filter","Boolean","join","templateName","existingContents","tmpl","find","t","locations","label","map","l","result","block","b","content","orphaned","some","push","getFormState","root","shadowRoot","editorMap","Map","querySelectorAll","ed","dataset","getValue","querySelector","value","description","e","ctrlKey","metaKey","key","preventDefault","locked","handleSave","handleTemplateChange","newTemplate","target","currentContents","handleDelete","confirm","async","confirmed","msg","success","setTimeout","href","handleMove","dirData","directories","parts","currentName","pop","currentDir","isValidDir","p","test","includes","dirToInternal","currentDirDisplay","$dialog","create","d","alert","confirmText","confirmAction","dirInput","newName","newDir","newFile","saveError","moveError","moved","encodeURIComponent","cancelText","updatePreview","dir","prefix","textContent","classList","toggle","handleReset","saved","c","current","x","updatedAt","render","isSystem","owner","isLocked","author","pageDir","slice","reachable","tDir","directory","pDir","startsWith","currentFound","templateLabel","createdAt","Date","toLocaleString","Object","keys","extraMetadata","length","entries","isDefault","headerTitle","toUpperCase","has","customElements","define"],"sources":["0"],"sourcesContent":["import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';\nimport { html, repeat } from '/kempo-ui/lit-all.min.js';\nimport { getPage, updatePage, listTemplates, deletePages, listDirectories, movePage } from '/kempo/sdk.js';\nimport Dialog from '/kempo-ui/components/Dialog.js';\nimport Toast from '/kempo-ui/components/Toast.js';\nimport '/kempo-ui/components/Icon.js';\nimport '/kempo-ui/components/Accordion.js';\nimport '/kempo-ui/components/HtmlEditor.js';\n\nconst CODE_MODE_LOCATIONS = new Set(['head', 'scripts']);\n\nconst slugify = name => name\n .toLowerCase()\n .trim()\n .replace(/[^\\w\\s-]/g, '')\n .replace(/[\\s_]+/g, '-')\n .replace(/-+/g, '-')\n .replace(/^-|-$/g, '');\n\nexport default class PageEditor extends ShadowComponent {\n static properties = {\n loading: { state: true },\n error: { state: true },\n saving: { state: true },\n page: { state: true },\n templates: { state: true },\n contents: { state: true }\n };\n\n constructor() {\n super();\n this.loading = true;\n this.error = false;\n this.saving = false;\n this.page = null;\n this.templates = [];\n this.contents = [];\n this.file = '';\n }\n\n async connectedCallback() {\n super.connectedCallback();\n document.addEventListener('keydown', this.handleKeyDown);\n this.file = new URLSearchParams(window.location.search).get('path') + '.page.html';\n\n if (!this.file || this.file === '.page.html') {\n this.loading = false;\n this.error = true;\n return;\n }\n\n const [[err, page], [, tmplData]] = await Promise.all([\n getPage(this.file),\n listTemplates()\n ]);\n\n this.loading = false;\n\n if (err) {\n this.error = true;\n return;\n }\n\n this.templates = tmplData?.templates || [];\n this.page = page;\n this.contents = this.buildContents(page.template, page.contents);\n document.title = `Edit: ${page.name || page.title || this.file} - Admin`;\n }\n\n disconnectedCallback() {\n super.disconnectedCallback();\n document.removeEventListener('keydown', this.handleKeyDown);\n }\n\n /*\n Helpers\n */\n\n getPageUrl() {\n const path = this.file.replace(/\\.page\\.html$/, '');\n return '/' + path.split('/').filter(Boolean).join('/');\n }\n\n buildContents(templateName, existingContents) {\n const tmpl = this.templates.find(t => t.name === templateName);\n const raw = tmpl?.locations || [{ name: 'default', label: null }];\n const locations = raw.map(l => typeof l === 'string' ? { name: l, label: null } : l);\n const result = locations.map(({ name, label }) => {\n const block = existingContents.find(b => b.location === name);\n return { location: name, label: label || null, content: block?.content || '', orphaned: false };\n });\n for (const block of existingContents) {\n if (!locations.some(l => l.name === block.location))\n result.push({ location: block.location, label: null, content: block.content, orphaned: true });\n }\n return result;\n }\n\n getFormState() {\n const root = this.shadowRoot;\n const editorMap = new Map(\n [...root.querySelectorAll('k-html-editor[data-location]')].map(ed => [ed.dataset.location, ed.getValue()])\n );\n return {\n name: root.querySelector('#metaName').value,\n title: root.querySelector('#metaTitle').value,\n description: root.querySelector('#metaDescription').value,\n template: root.querySelector('#metaTemplate').value,\n contents: this.contents.map(({ location, content, orphaned }) => ({\n location,\n content: orphaned\n ? (editorMap.get(location)?.trim() || content)\n : (editorMap.get(location) ?? content)\n }))\n };\n }\n\n /*\n Event Handlers\n */\n\n handleKeyDown = e => {\n if((e.ctrlKey || e.metaKey) && e.key === 's'){\n e.preventDefault();\n if(!this.saving && !this.page?.locked) this.handleSave();\n }\n };\n\n handleTemplateChange = e => {\n if(this.page?.locked) return;\n const newTemplate = e.target.value;\n const editorMap = new Map(\n [...this.shadowRoot.querySelectorAll('k-html-editor[data-location]')].map(ed => [ed.dataset.location, ed.getValue()])\n );\n // Fall back to state content for any editor that hasn't initialized (Monaco lazy-loads)\n const currentContents = this.contents.map(({ location, content }) => ({\n location,\n content: editorMap.get(location)?.trim() || content\n }));\n this.contents = this.buildContents(newTemplate, currentContents);\n this.page = { ...this.page, template: newTemplate };\n };\n\n handleDelete = () => {\n Dialog.confirm('Delete this page? This action cannot be undone.', async confirmed => {\n if (!confirmed) return;\n const [error] = await deletePages([this.file]);\n if (error) {\n Toast.error(error.msg || 'Failed to delete page');\n return;\n }\n Toast.success('Page deleted');\n setTimeout(() => { window.location.href = '/admin/pages'; }, 1000);\n });\n };\n\n handleMove = async () => {\n const [, dirData] = await listDirectories();\n const directories = dirData?.directories || ['.'];\n\n const parts = this.file.replace(/\\.page\\.html$/, '').split('/');\n const currentName = parts.pop();\n const currentDir = parts.join('/') || '.';\n\n const isValidDir = p => /^\\/[a-zA-Z0-9_\\-\\[\\]\\/]*$/.test(p) && !p.includes('..') && !p.includes('//');\n const dirToInternal = p => { const t = (p || '/').replace(/^\\//, '').replace(/\\/$/, ''); return t || '.'; };\n const currentDirDisplay = currentDir === '.' ? '/' : '/' + currentDir;\n\n const openLearnMore = () => Dialog.alert(html`\n <div class=\"p\">\n <p>Directory paths define where a page lives in the URL structure.</p>\n <p><strong>Examples:</strong></p>\n <ul>\n <li><code>/</code> — root, e.g. <code>/about</code></li>\n <li><code>/blog</code> — e.g. <code>/blog/my-post</code></li>\n <li><code>/blog/[slug]</code> — dynamic segment; <code>[slug]</code> matches any value</li>\n </ul>\n <p>Dynamic segments like <code>[token]</code> allow a single page to handle many URLs. The segment name is available to the page as a URL parameter.</p>\n <p>You can nest segments: <code>/store/[category]/[id]</code></p>\n </div>\n `, null, { title: 'About Directory Paths' });\n\n const $dialog = Dialog.create(html`\n <div class=\"p\">\n <div class=\"mb\">\n <label class=\"d-b mb-sm\"><strong>Location</strong></label>\n <input type=\"text\" id=\"dlg-move-dir\" class=\"full\" list=\"dlg-move-dir-list\" .value=\"${currentDirDisplay}\" placeholder=\"/\">\n <datalist id=\"dlg-move-dir-list\">\n ${directories.map(d => html`<option value=\"${d === '.' ? '/' : '/' + d}\">`)} \n </datalist>\n <p id=\"dlg-move-dir-error\" class=\"mt-sm tc-danger d-n\"><small>Invalid path — use format: /path/to/directory</small></p>\n <p class=\"mt-sm muted\"><small>Supports dynamic segments e.g. <code>/blog/[slug]</code>. <button class=\"link\" @click=\"${openLearnMore}\">Learn more</button></small></p>\n </div>\n <div>\n <label class=\"d-b mb-sm\"><strong>Page Name</strong></label>\n <input type=\"text\" id=\"dlg-move-name\" class=\"full\" .value=\"${currentName}\">\n <p class=\"mt-sm muted\"><small id=\"dlg-move-preview\"></small></p>\n </div>\n </div>\n `, {\n title: 'Move Page',\n confirmText: 'Save and Move Page',\n confirmAction: async () => {\n const dirInput = $dialog.querySelector('#dlg-move-dir').value.trim() || '/';\n const newName = slugify($dialog.querySelector('#dlg-move-name').value.trim());\n if (!newName) {\n Toast.error('Page name is required');\n return;\n }\n if (!isValidDir(dirInput)) {\n Toast.error('Invalid directory path');\n return;\n }\n const newDir = dirToInternal(dirInput);\n const newFile = newDir === '.'\n ? `${newName}.page.html`\n : `${newDir}/${newName}.page.html`;\n\n this.saving = true;\n const [saveError] = await updatePage({ file: this.file, ...this.getFormState() });\n this.saving = false;\n if (saveError) {\n Toast.error(saveError.msg || 'Failed to save page');\n return;\n }\n\n const [moveError, moved] = await movePage({ file: this.file, newFile });\n if (moveError) {\n Toast.error(moveError.msg || 'Failed to move page');\n return;\n }\n\n Toast.success('Page saved and moved');\n setTimeout(() => {\n window.location.href = `/admin/content/pages/edit?path=${encodeURIComponent(moved.file.replace(/\\.page\\.html$/, ''))}`;\n }, 1000);\n },\n cancelText: 'Cancel'\n });\n\n const updatePreview = () => {\n const dir = dirToInternal($dialog.querySelector('#dlg-move-dir').value || '/');\n const name = slugify($dialog.querySelector('#dlg-move-name').value);\n const prefix = dir === '.' ? '/' : '/' + dir + '/';\n $dialog.querySelector('#dlg-move-preview').textContent = name ? prefix + name : '';\n };\n $dialog.querySelector('#dlg-move-dir').addEventListener('input', e => {\n $dialog.querySelector('#dlg-move-dir-error').classList.toggle('d-n', isValidDir(e.target.value || '/'));\n updatePreview();\n });\n $dialog.querySelector('#dlg-move-name').addEventListener('input', updatePreview);\n updatePreview();\n };\n\n handleReset = () => {\n Dialog.confirm('Reset this page? Any unsaved changes will be lost.', confirmed => {\n if (!confirmed) return;\n this.contents = this.buildContents(this.page.template, this.page.contents);\n this.page = { ...this.page };\n });\n };\n\n handleSave = async () => {\n this.saving = true;\n const state = this.getFormState();\n const [saveError, saved] = await updatePage({ file: this.file, ...state });\n this.saving = false;\n\n if (saveError) {\n Toast.error(saveError.msg || 'Failed to save page');\n return;\n }\n\n this.contents = this.contents.map(c => {\n const current = state.contents.find(x => x.location === c.location);\n return current ? { ...c, content: current.content } : c;\n });\n this.page = {\n ...this.page,\n ...state,\n contents: state.contents.filter(b => b.content.trim()),\n updatedAt: saved.updatedAt\n };\n\n document.title = `Edit: ${state.name || state.title || this.file} - Admin`;\n Toast.success('Page saved');\n };\n\n /*\n Render\n */\n\n render() {\n if (this.loading) return html`<div>Loading page...</div>`;\n\n if (this.error) return html`\n <div>\n <p>Page not found.</p>\n <a href=\"/admin/pages\">Back to Pages</a>\n </div>\n `;\n\n const { page, templates, contents } = this;\n const isSystem = page.owner === 'system';\n const isLocked = page.locked;\n\n return html`\n ${isLocked ? html`\n <div class=\"p r mb bc-warning tc-warning\">\n <k-icon name=\"lock\"></k-icon> This page is locked and cannot be edited. ${page.owner === 'custom' ? 'Locked by developer' : `Managed by: ${page.owner || 'external system'}`}\n </div>\n ` : ''}\n <div class=\"d-f mb\">\n <div class=\"btn-grp mrh mb\">\n <a href=\"/admin/content/pages\" class=\"btn\">\n <k-icon name=\"arrow\" direction=\"left\"></k-icon> Pages\n </a>\n <a href=\"${this.getPageUrl()}\" id=\"page-link\" class=\"btn\">\n <k-icon name=\"visibility\"></k-icon> View Page\n </a>\n </div>\n \n <div class=\"flex\"></div>\n ${!isSystem && !isLocked ? html`\n <div class=\"btn-grp mrh mb\">\n <button class=\"danger\" @click=\"${this.handleDelete}\"><k-icon name=\"delete\"></k-icon> Delete</button>\n <button @click=\"${this.handleMove}\"><k-icon name=\"drive_file_move\"></k-icon> Move Page</button>\n </div>\n ` : ''}\n ${!isLocked ? html`\n <div class=\"btn-grp mrh mb\">\n <button @click=\"${this.handleReset}\"><k-icon name=\"restart_alt\"></k-icon> Reset</button>\n <button id=\"saveBtn\" class=\"primary\" ?disabled=\"${this.saving}\" @click=\"${this.handleSave}\">\n <k-icon name=\"save\"></k-icon> Save\n </button>\n </div>\n ` : ''}\n </div>\n <h1 class=\"mb0\">Page: ${page.name || page.title || this.file}</h1>\n\n <a href=\"${this.getPageUrl()}\" class=\"d-b mb\"><code>${this.getPageUrl()}</code></a>\n\n <k-accordion>\n <k-accordion-header for-panel=\"metadata\">Metadata</k-accordion-header>\n <k-accordion-panel name=\"metadata\">\n <div class=\"p\">\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Name</strong></label>\n <input type=\"text\" id=\"metaName\" class=\"flex\" .value=\"${page.name || ''}\" ?disabled=\"${isSystem || isLocked}\">\n </div>\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Title</strong></label>\n <input type=\"text\" id=\"metaTitle\" class=\"flex\" .value=\"${page.title || ''}\" ?disabled=\"${isLocked}\">\n </div>\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Description</strong></label>\n <input type=\"text\" id=\"metaDescription\" class=\"flex\" .value=\"${page.description || ''}\" ?disabled=\"${isSystem || isLocked}\">\n </div>\n ${!isSystem ? html`\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Author</strong></label>\n <span class=\"muted\">${page.author || ''}</span>\n </div>\n ` : ''}\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Template</strong></label>\n ${(() => {\n const pageDir = this.file.replace(/\\.page\\.html$/, '').split('/').slice(0, -1).join('/') || '.';\n const reachable = templates.filter(t => {\n const tDir = t.directory === '.' ? '' : t.directory;\n const pDir = pageDir === '.' ? '' : pageDir;\n return tDir === '' || pDir === tDir || pDir.startsWith(tDir + '/');\n });\n const templateLabel = t => t.directory === '.' ? `/${t.name}` : `/${t.directory}/${t.name}`;\n const currentFound = reachable.some(t => t.name === page.template);\n return html`\n <select id=\"metaTemplate\" class=\"flex\" @change=\"${this.handleTemplateChange}\" ?disabled=\"${isLocked}\">\n ${!currentFound ? html`<option value=\"${page.template}\" selected>${page.template} (Not Found, using default)</option>` : ''}\n ${reachable.map(t => html`\n <option value=\"${t.name}\" ?selected=\"${t.name === page.template}\">${templateLabel(t)}</option>\n `)}\n </select>\n `;\n })()}\n </div>\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Owner</strong></label>\n <span class=\"muted\">${page.owner || ''}</span>\n </div>\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Created</strong></label>\n <span class=\"muted\">${page.createdAt ? new Date(page.createdAt).toLocaleString() : ''}</span>\n </div>\n <div class=\"d-f\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\"><strong>Updated</strong></label>\n <span class=\"muted\">${page.updatedAt ? new Date(page.updatedAt).toLocaleString() : ''}</span>\n </div>\n </div>\n </k-accordion-panel>\n ${Object.keys(page.extraMetadata || {}).length ? html`\n <k-accordion-header for-panel=\"extra-metadata\">Extra Metadata</k-accordion-header>\n <k-accordion-panel name=\"extra-metadata\">\n <div class=\"p\">\n ${Object.entries(page.extraMetadata).map(([key, value]) => html`\n <div class=\"d-f mb\" style=\"align-items: center; gap: var(--spacer);\">\n <label style=\"min-width: 120px;\" class=\"muted\"><strong>${key}</strong></label>\n <span>${value}</span>\n </div>\n `)}\n <p class=\"muted mt0\"><small>Extra metadata is managed by extensions and cannot be edited here.</small></p>\n </div>\n </k-accordion-panel>\n ` : ''}\n ${repeat(contents, c => c.location, ({ location, label, content, orphaned }) => {\n const isDefault = location === 'default';\n const headerTitle = label || (isDefault ? 'Content' : location.slice(0, 1).toUpperCase() + location.slice(1));\n return html`\n <k-accordion-header for-panel=\"content-${location}\" ?active=\"${isDefault}\">\n ${headerTitle}${orphaned ? html` <span class=\"tc-warning\"><small>(not in template)</small></span>` : ''}\n </k-accordion-header>\n <k-accordion-panel name=\"content-${location}\" ?active=\"${isDefault}\">\n <div class=\"p\">\n <k-html-editor\n class=\"r b\"\n data-location=\"${location}\"\n controls=\"full\"\n .value=\"${content}\"\n mode=\"${CODE_MODE_LOCATIONS.has(location) ? 'code' : 'visual'}\"\n style=\"height: 400px;\"\n ?disabled=\"${isLocked}\"\n ></k-html-editor>\n </div>\n </k-accordion-panel>\n `;\n })}\n </k-accordion>\n `;\n }\n}\n\ncustomElements.define('admin-page-editor', PageEditor);\n"],"mappings":"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","ignoreList":[]}
@@ -93,7 +93,7 @@
93
93
  const myPerms = new Set(permData?.permissions ?? []);
94
94
 
95
95
  const fields = [
96
- { name: 'lockedBadge', label: '', size: 40, formatter: v => v },
96
+ { name: 'locked', label: '', size: 40, calculator: r => r.locked ? html`<k-icon name="lock"></k-icon>` : html`` },
97
97
  { name: 'name', label: 'Name', size: 200 },
98
98
  { name: 'file', label: 'File', size: 300 },
99
99
  { name: 'author', label: 'Author', size: 150 },
@@ -116,7 +116,7 @@
116
116
  Toast.error(error.msg || 'Failed to load fragments');
117
117
  return;
118
118
  }
119
- const toEditPath = f => ({ ...f, editPath: f.file.replace(/\.fragment\.html$/, ''), lockedBadge: f.locked ? '<k-icon name="lock"></k-icon>' : '' });
119
+ const toEditPath = f => ({ ...f, editPath: f.file.replace(/\.fragment\.html$/, '') });
120
120
  const custom = data.fragments.filter(f => f.owner === 'custom').map(toEditPath);
121
121
  const extension = data.fragments.filter(f => f.owner !== 'custom' && f.owner !== 'system').map(toEditPath);
122
122
  const system = data.fragments.filter(f => f.owner === 'system').map(toEditPath);
@@ -94,7 +94,7 @@
94
94
  const myPerms = new Set(permData?.permissions ?? []);
95
95
 
96
96
  const fields = [
97
- { name: 'lockedBadge', label: '', size: 40, formatter: v => v },
97
+ { name: 'locked', label: '', size: 40, calculator: r => r.locked ? html`<k-icon name="lock"></k-icon>` : html`` },
98
98
  { name: 'name', label: 'Name', size: 200 },
99
99
  { name: 'location', label: 'Location', size: 150 },
100
100
  { name: 'priority', label: 'Priority', size: 80 },
@@ -112,17 +112,15 @@
112
112
  const $extensionTable = document.getElementById('extensionTable');
113
113
  const $systemTable = document.getElementById('systemTable');
114
114
 
115
- const addBadge = e => ({ ...e, lockedBadge: e.locked ? '<k-icon name="lock"></k-icon>' : '' });
116
-
117
115
  const loadEntries = async () => {
118
116
  const [error, data] = await listGlobalContent();
119
117
  if(error){
120
118
  Toast.error(error.msg || 'Failed to load global content');
121
119
  return;
122
120
  }
123
- const custom = data.entries.filter(e => e.owner === 'custom').map(addBadge);
124
- const extension = data.entries.filter(e => e.owner !== 'custom' && e.owner !== 'system').map(addBadge);
125
- const system = data.entries.filter(e => e.owner === 'system').map(addBadge);
121
+ const custom = data.entries.filter(e => e.owner === 'custom');
122
+ const extension = data.entries.filter(e => e.owner !== 'custom' && e.owner !== 'system');
123
+ const system = data.entries.filter(e => e.owner === 'system');
126
124
  $customTable.setData({ records: custom, fields });
127
125
  $extensionTable.setData({ records: extension, fields: extensionFields });
128
126
  $systemTable.setData({ records: system, fields });
@@ -117,7 +117,7 @@
117
117
  >
118
118
  <admin-table-row-link
119
119
  slot="before"
120
- url-pattern="/admin/pages/edit?path={editPath}"
120
+ url-pattern="/admin/content/pages/edit?path={editPath}"
121
121
  field-name="editPath"
122
122
  ></admin-table-row-link>
123
123
  <div
@@ -147,7 +147,7 @@
147
147
  >
148
148
  <admin-table-row-link
149
149
  slot="before"
150
- url-pattern="/admin/pages/edit?path={editPath}"
150
+ url-pattern="/admin/content/pages/edit?path={editPath}"
151
151
  field-name="editPath"
152
152
  ></admin-table-row-link>
153
153
  <div
@@ -392,7 +392,7 @@
392
392
 
393
393
  Toast.success('Page created — redirecting to editor...');
394
394
  setTimeout(() => {
395
- window.location.href = `/admin/pages/edit?path=${encodeURIComponent(data.file.replace(/\.page\.html$/, ''))}`;
395
+ window.location.href = `/admin/content/pages/edit?path=${encodeURIComponent(data.file.replace(/\.page\.html$/, ''))}`;
396
396
  }, 1500);
397
397
  },
398
398
  cancelText: 'Cancel'
@@ -123,7 +123,7 @@
123
123
  const myPerms = new Set(permData?.permissions ?? []);
124
124
 
125
125
  const fields = [
126
- { name: 'lockedBadge', label: '', size: 40, formatter: v => v },
126
+ { name: 'locked', label: '', size: 40, calculator: r => r.locked ? html`<k-icon name="lock"></k-icon>` : html`` },
127
127
  { name: 'name', label: 'Name', size: 150 },
128
128
  { name: 'directory', label: 'Directory', size: 200 },
129
129
  { name: 'author', label: 'Author', size: 150 },
@@ -149,8 +149,7 @@
149
149
  const toEditPath = t => ({
150
150
  ...t,
151
151
  directory: t.directory === '.' ? '/' : t.directory,
152
- editPath: (t.directory === '.' ? t.name : `${t.directory}/${t.name}`),
153
- lockedBadge: t.locked ? '<k-icon name="lock"></k-icon>' : ''
152
+ editPath: (t.directory === '.' ? t.name : `${t.directory}/${t.name}`)
154
153
  });
155
154
  const custom = data.templates.filter(t => t.owner === 'custom').map(toEditPath);
156
155
  const extension = data.templates.filter(t => t.owner !== 'custom' && t.owner !== 'system').map(toEditPath);
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M640-480v-80h80v80h-80Zm0 80h-80v-80h80v80Zm0 80v-80h80v80h-80ZM447-640l-80-80H160v480h400v-80h80v80h160v-400H640v80h-80v-80H447ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M287-167q-47-47-47-113t47-113q47-47 113-47 23 0 42.5 5.5T480-418v-422h240v160H560v400q0 66-47 113t-113 47q-66 0-113-47Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M256-240h84l44-122h192l44 122h84L522-720h-84L256-240Zm152-192 70-198h4l70 198H408ZM160-80q-33 0-56.5-23.5T80-160v-640q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v640q0 33-23.5 56.5T800-80H160Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M440-181 240-296q-19-11-29.5-29T200-365v-230q0-22 10.5-40t29.5-29l200-115q19-11 40-11t40 11l200 115q19 11 29.5 29t10.5 40v230q0 22-10.5 40T720-296L520-181q-19 11-40 11t-40-11Zm0-92v-184l-160-93v185l160 92Zm80 0 160-92v-185l-160 93v184ZM80-680v-120q0-33 23.5-56.5T160-880h120v80H160v120H80ZM280-80H160q-33 0-56.5-23.5T80-160v-120h80v120h120v80Zm400 0v-80h120v-120h80v120q0 33-23.5 56.5T800-80H680Zm120-600v-120H680v-80h120q33 0 56.5 23.5T880-800v120h-80ZM480-526l158-93-158-91-158 91 158 93Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="m160-800 80 160h120l-80-160h80l80 160h120l-80-160h80l80 160h120l-80-160h120q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800Zm0 240v320h640v-320H160Zm0 0v320-320Z"/></svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo",
3
- "version": "4.2.26",
3
+ "version": "4.2.28",
4
4
  "type": "module",
5
5
  "description": "A fullstack CMS framework for creating websites with user authentication",
6
6
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  import db from '../../db/index.js';
2
2
  import { hook } from '../../db/schema.js';
3
- import { eq } from 'drizzle-orm';
3
+ import { eq, asc } from 'drizzle-orm';
4
4
  import { join } from 'path';
5
5
  import { pathToFileURL } from 'url';
6
6
 
@@ -15,13 +15,36 @@ const resolveHandler = (owner, callback) => {
15
15
  return callback;
16
16
  };
17
17
 
18
+ /*
19
+ Fires every handler registered for `event`, in registration order, awaiting each in turn.
20
+
21
+ Two contracts share this one implementation:
22
+
23
+ - **Guard** (`bail: true`) — a handler throwing aborts the whole chain and the error propagates to
24
+ the caller, which is how `middleware:before_page` lets an extension veto a page render.
25
+ - **Notification** (the default) — every handler runs regardless of what the others did, and a
26
+ handler that throws is recorded in `results` rather than stopping anything.
27
+
28
+ The notification contract is also what a *collaborative* hook is built on: `data` is passed to
29
+ each handler by reference, so an event whose payload carries a mutable draft (see
30
+ `app-public/CATCH.js` and its `route:unmatched` draft response) lets each handler inspect what
31
+ earlier ones decided and add to it, without any handler being able to end the chain early or
32
+ write a response mid-flight. That needs no special mode here — only that ordering be predictable,
33
+ hence the explicit `orderBy` below, since without it row order is whatever the database returns.
34
+
35
+ Note that "notification" governs error handling, not timing: handlers are still awaited in
36
+ sequence, so a handler doing slow work holds up whatever triggered the event. Such a handler
37
+ should start its work and return rather than awaiting it here.
38
+ */
18
39
  export default async (event, data = {}, { bail = false } = {}) => {
19
40
  if(!event){
20
41
  return [{ code: 400, msg: 'Event is required' }, null];
21
42
  }
22
43
 
23
44
  try {
24
- const hooks = await db.select().from(hook).where(eq(hook.event, event));
45
+ const hooks = await db.select().from(hook)
46
+ .where(eq(hook.event, event))
47
+ .orderBy(asc(hook.createdAt), asc(hook.id));
25
48
  const results = [];
26
49
 
27
50
  for(const h of hooks){
@@ -0,0 +1,113 @@
1
+ import { join } from 'path';
2
+ import { renderExternalPage } from 'kempo-server/templating';
3
+ import triggerHook from '../hooks/triggerHook.js';
4
+
5
+ /*
6
+ Imported only when a handler actually serves a file. `kempo-server/serve-static-file` is a newer
7
+ export than kempo's minimum peer version, and loading it eagerly would make this whole module —
8
+ and so every 404 on the site — fail to load against an older kempo-server, for the sake of a
9
+ branch most sites never reach.
10
+ */
11
+ const loadStaticFileServer = async () => {
12
+ try {
13
+ return (await import('kempo-server/serve-static-file')).default;
14
+ } catch {
15
+ throw new Error(
16
+ 'A route:unmatched handler set draft.filePath, which needs kempo-server >= 3.3.0. Upgrade kempo-server, or have the handler set draft.body instead.'
17
+ );
18
+ }
19
+ };
20
+
21
+ /*
22
+ Answers a request nothing else claimed. Driven by the site's public/CATCH.js, which is reached for
23
+ every unmatched URL on the site at any depth — kempo-server walks upward from the deepest
24
+ directory a URL implies looking for a CATCH file, so the one at the site root is the final
25
+ fallback, with no prefix or configuration involved.
26
+
27
+ That makes this the one place an extension can answer a request for something with no file behind
28
+ it: a download served out of a directory the static scanner deliberately cannot see, a short
29
+ link, a generated feed.
30
+
31
+ Rather than hand each handler the live response, the `route:unmatched` hook carries a **draft**
32
+ that handlers fill in, and the single real write happens here once they have all run. Nothing is
33
+ sent mid-chain, so a handler cannot end the request early and silence the ones after it — an
34
+ extension that logs every 404 still sees requests another extension answered. Handlers run in
35
+ registration order and each can see what earlier ones decided via `draft.handled`, so they can
36
+ defer to each other, or add to a response already in progress.
37
+
38
+ Anything slow belongs in a background task rather than awaited in a handler: this runs on every
39
+ unmatched request, and the response waits on it.
40
+ */
41
+ export default async (request, response, publicDir) => {
42
+ const url = request.url.split('?')[0];
43
+
44
+ const draft = {
45
+ status: null,
46
+ headers: {},
47
+ body: null,
48
+ filePath: null,
49
+ handled: false,
50
+ };
51
+
52
+ /*
53
+ Deliberately not a bailing hook: one extension throwing must not take the site's 404 page down
54
+ with it, nor stop the handlers registered after it. triggerHook records the error and continues.
55
+ */
56
+ await triggerHook('route:unmatched', {
57
+ url,
58
+ method: request.method,
59
+ request,
60
+ draft,
61
+ });
62
+
63
+ /*
64
+ A file wins over an inline body: it is the only one of the two that needs streaming, and
65
+ serveStaticFile is what gives it Range/206 support, so a gated video still seeks. The handler
66
+ only described what to serve — the bytes still go out through the same helper every static file
67
+ on the site uses.
68
+ */
69
+ if(draft.filePath){
70
+ const serveStaticFile = await loadStaticFileServer();
71
+ const { 'Content-Type': contentType, ...rest } = draft.headers;
72
+ await serveStaticFile(draft.filePath, request, response, {}, undefined, { contentType, headers: rest });
73
+ return;
74
+ }
75
+
76
+ if(draft.body !== null){
77
+ response.writeHead(draft.status || 200, draft.headers);
78
+ response.end(draft.body);
79
+ return;
80
+ }
81
+
82
+ /*
83
+ `handled` with neither body nor file means a handler wrote a status and nothing else — a
84
+ redirect, say, or a bare 204.
85
+ */
86
+ if(draft.handled){
87
+ response.writeHead(draft.status || 204, draft.headers);
88
+ response.end();
89
+ return;
90
+ }
91
+
92
+ /*
93
+ resolveDir is built from the request URL rather than from where the CATCH file lives, so
94
+ {{pathToRoot}} in the 404 page points back up from the depth the visitor actually asked for.
95
+ */
96
+ let resolveDir = publicDir;
97
+ for(const part of url.replace(/\/+$/, '').split('/').filter(Boolean)) resolveDir = join(resolveDir, part);
98
+
99
+ /*
100
+ Handlers that added headers without claiming the request keep them here — a handler annotating
101
+ every response (a request id, a cache directive) should not have its work thrown away just
102
+ because nothing served a body. The content type is applied last so it cannot be clobbered.
103
+ */
104
+ try {
105
+ const html = await renderExternalPage(join(publicDir, 'CATCH.page.html'), publicDir, resolveDir);
106
+ response.writeHead(404, { ...draft.headers, 'Content-Type': 'text/html; charset=utf-8' });
107
+ response.end(html);
108
+ } catch {
109
+ // CATCH.page.html was deleted or fails to render — still answer, rather than hanging the request
110
+ response.writeHead(404, { ...draft.headers, 'Content-Type': 'text/plain; charset=utf-8' });
111
+ response.end('Not Found');
112
+ }
113
+ };