kempo 4.2.27 → 4.2.30
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.
- package/app-public/CATCH.js +53 -0
- package/dist/admin/components/PageEditor.js +1 -1
- package/dist/admin/components/PageEditor.js.map +1 -1
- package/dist/admin/content/pages/index.page.html +3 -3
- package/dist/kempo/icons/archive.svg +1 -0
- package/dist/kempo/icons/audio.svg +1 -0
- package/dist/kempo/icons/font.svg +1 -0
- package/dist/kempo/icons/model3d.svg +1 -0
- package/dist/kempo/icons/video.svg +1 -0
- package/package.json +1 -1
- package/server/utils/hooks/triggerHook.js +25 -2
- package/server/utils/routing/serveUnmatched.js +113 -0
|
@@ -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":[]}
|
|
@@ -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'
|
|
@@ -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
|
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)
|
|
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
|
+
};
|