1
0

Big page refactor

This commit is contained in:
Zef Hemel 2022-02-26 13:26:31 +01:00
parent 3cf84af894
commit 03e1eb2353
19 changed files with 214 additions and 204 deletions

2
.gitignore vendored
View File

@ -1 +1 @@
nuggets
pages

13
plugins/core/click.ts Normal file
View File

@ -0,0 +1,13 @@
import { ClickEvent } from "../../webapp/src/app_event.ts";
import { syscall } from "./lib/syscall.ts";
export default async function click(event: ClickEvent) {
console.log("Event", event);
if (event.ctrlKey || event.metaKey) {
let syntaxNode = await syscall("editor.getSyntaxNodeAtPos", event.pos);
console.log("Here", syntaxNode);
if (syntaxNode && syntaxNode.name === "WikiLinkPage") {
await syscall("editor.navigate", syntaxNode.text);
}
}
}

View File

@ -1,16 +1,12 @@
{
"commands": {
"Count Words": {
"invoke": "word_count_command",
"requiredContext": {
"text": true
}
"invoke": "word_count_command"
},
"Navigate To page": {
"invoke": "link_navigate",
"key": "Ctrl-Enter",
"mac": "Cmd-Enter",
"requiredContext": {}
"mac": "Cmd-Enter"
},
"Insert Current Date": {
"invoke": "insert_nice_date",
@ -28,12 +24,16 @@
}
},
"events": {
"ready": ["welcome"]
"app:ready": ["welcome"],
"page:click": ["click"]
},
"functions": {
"welcome": {
"path": "./welcome.ts"
},
"click": {
"path": "./click.ts"
},
"word_count_command": {
"path": "./word_count_command.ts:wordCount"
},

View File

@ -1,6 +1,6 @@
import { syscall } from "./lib/syscall.ts";
export async function linkNavigate({ text }: { text: string }) {
export async function linkNavigate() {
let syntaxNode = await syscall("editor.getSyntaxNodeUnderCursor");
if (syntaxNode && syntaxNode.name === "WikiLinkPage") {
await syscall("editor.navigate", syntaxNode.text);

View File

@ -6,21 +6,21 @@ import { oakCors } from "https://deno.land/x/cors@v1.2.0/mod.ts";
import { readAll } from "https://deno.land/std@0.126.0/streams/mod.ts";
import { exists } from "https://deno.land/std@0.126.0/fs/mod.ts";
type NuggetMeta = {
type PageMeta = {
name: string;
lastModified: number;
};
const fsPrefix = "/fs";
const nuggetsPath = "../pages";
const pagesPath = "../pages";
const fsRouter = new Router();
fsRouter.use(oakCors({ methods: ["OPTIONS", "GET", "PUT", "POST"] }));
fsRouter.get("/", async (context) => {
const localPath = nuggetsPath;
let fileNames: NuggetMeta[] = [];
const localPath = pagesPath;
let fileNames: PageMeta[] = [];
for await (const dirEntry of Deno.readDir(localPath)) {
if (dirEntry.isFile) {
const stat = await Deno.stat(`${localPath}/${dirEntry.name}`);
@ -36,9 +36,9 @@ fsRouter.get("/", async (context) => {
context.response.body = JSON.stringify(fileNames);
});
fsRouter.get("/:nugget", async (context) => {
const nuggetName = context.params.nugget;
const localPath = `${nuggetsPath}/${nuggetName}.md`;
fsRouter.get("/:page", async (context) => {
const pageName = context.params.page;
const localPath = `${pagesPath}/${pageName}.md`;
try {
const stat = await Deno.stat(localPath);
const text = await Deno.readTextFile(localPath);
@ -50,8 +50,8 @@ fsRouter.get("/:nugget", async (context) => {
}
});
fsRouter.options("/:nugget", async (context) => {
const localPath = `${nuggetsPath}/${context.params.nugget}.md`;
fsRouter.options("/:page", async (context) => {
const localPath = `${pagesPath}/${context.params.page}.md`;
try {
const stat = await Deno.stat(localPath);
context.response.headers.set("Content-length", `${stat.size}`);
@ -63,10 +63,10 @@ fsRouter.options("/:nugget", async (context) => {
}
});
fsRouter.put("/:nugget", async (context) => {
const nuggetName = context.params.nugget;
const localPath = `${nuggetsPath}/${nuggetName}.md`;
const existingNugget = await exists(localPath);
fsRouter.put("/:page", async (context) => {
const pageName = context.params.page;
const localPath = `${pagesPath}/${pageName}.md`;
const existingPage = await exists(localPath);
let file;
try {
file = await Deno.create(localPath);
@ -82,7 +82,7 @@ fsRouter.put("/:nugget", async (context) => {
file.close();
const stat = await Deno.stat(localPath);
console.log("Wrote to", localPath);
context.response.status = existingNugget ? 200 : 201;
context.response.status = existingPage ? 200 : 201;
context.response.headers.set("Last-Modified", "" + stat.mtime?.getTime());
context.response.body = "OK";
});

8
webapp/src/app_event.ts Normal file
View File

@ -0,0 +1,8 @@
export type AppEvent = "app:ready" | "page:save" | "page:load" | "page:click";
export type ClickEvent = {
pos: number;
metaKey: boolean;
ctrlKey: boolean;
altKey: boolean;
};

View File

@ -1,9 +1,9 @@
import { Editor } from "./editor";
import { HttpFileSystem } from "./fs";
import { HttpRemoteSpace } from "./space";
import { safeRun } from "./util";
let editor = new Editor(
new HttpFileSystem(`http://${location.hostname}:2222/fs`),
new HttpRemoteSpace(`http://${location.hostname}:2222/fs`),
document.getElementById("root")!
);

View File

@ -1,13 +0,0 @@
import { Editor } from "./editor";
import { AppCommand, CommandContext } from "./types";
export function buildContext(cmd: AppCommand, editor: Editor) {
let ctx: CommandContext = {};
if (!cmd.command.requiredContext) {
return ctx;
}
if (cmd.command.requiredContext.text) {
ctx.text = editor.editorView?.state.sliceDoc();
}
return ctx;
}

View File

@ -1,16 +1,16 @@
import { NuggetMeta } from "../types";
import { PageMeta } from "../types";
export function NavigationBar({
currentNugget,
currentPage,
onClick,
}: {
currentNugget?: NuggetMeta;
currentPage?: PageMeta;
onClick: () => void;
}) {
return (
<div id="top">
<div className="current-nugget" onClick={onClick}>
» {currentNugget?.name}
<div className="current-page" onClick={onClick}>
» {currentPage?.name}
</div>
</div>
);

View File

@ -1,23 +1,23 @@
import { NuggetMeta } from "../types";
import { PageMeta } from "../types";
import { FilterList } from "./filter";
export function NuggetNavigator({
allNuggets: allNuggets,
export function PageNavigator({
allPages: allPages,
onNavigate,
}: {
allNuggets: NuggetMeta[];
onNavigate: (nugget: string | undefined) => void;
allPages: PageMeta[];
onNavigate: (page: string | undefined) => void;
}) {
return (
<FilterList
placeholder=""
options={allNuggets.map((meta) => ({
options={allPages.map((meta) => ({
...meta,
// Order by last modified date in descending order
orderId: -meta.lastModified.getTime(),
}))}
allowNew={true}
newHint="Create nugget"
newHint="Create page"
onSelect={(opt) => {
onNavigate(opt?.name);
}}

View File

@ -23,13 +23,12 @@ import {
import React, { useEffect, useReducer } from "react";
import ReactDOM from "react-dom";
import coreManifest from "../../plugins/dist/core.plugin.json";
import { buildContext } from "./buildContext";
import * as commands from "./commands";
import { CommandPalette } from "./components/command_palette";
import { NavigationBar } from "./components/navigation_bar";
import { NuggetNavigator } from "./components/nugget_navigator";
import { PageNavigator } from "./components/page_navigator";
import { StatusBar } from "./components/status_bar";
import { FileSystem } from "./fs";
import { Space } from "./space";
import { lineWrapper } from "./lineWrapper";
import { markdown } from "./markdown";
import customMarkDown from "./parser";
@ -43,20 +42,19 @@ import editorSyscalls from "./syscalls/editor.browser";
import {
Action,
AppCommand,
AppEvent,
AppViewState,
CommandContext,
initialViewState,
NuggetMeta,
PageMeta,
} from "./types";
import { AppEvent, ClickEvent } from "./app_event";
import { safeRun } from "./util";
class NuggetState {
class PageState {
editorState: EditorState;
scrollTop: number;
meta: NuggetMeta;
meta: PageMeta;
constructor(editorState: EditorState, scrollTop: number, meta: NuggetMeta) {
constructor(editorState: EditorState, scrollTop: number, meta: PageMeta) {
this.editorState = editorState;
this.scrollTop = scrollTop;
this.meta = meta;
@ -70,14 +68,14 @@ export class Editor {
viewState: AppViewState;
viewDispatch: React.Dispatch<Action>;
$hashChange?: () => void;
openNuggets: Map<string, NuggetState>;
fs: FileSystem;
openPages: Map<string, PageState>;
fs: Space;
editorCommands: Map<string, AppCommand>;
plugins: Plugin[];
constructor(fs: FileSystem, parent: Element) {
constructor(fs: Space, parent: Element) {
this.editorCommands = new Map();
this.openNuggets = new Map();
this.openPages = new Map();
this.plugins = [];
this.fs = fs;
this.viewState = initialViewState;
@ -92,11 +90,11 @@ export class Editor {
}
async init() {
await this.loadNuggetList();
await this.loadPageList();
await this.loadPlugins();
this.$hashChange!();
this.focus();
await this.dispatchAppEvent("ready");
await this.dispatchAppEvent("app:ready");
}
async loadPlugins() {
@ -123,7 +121,7 @@ export class Editor {
let cmd = cmds[name];
this.editorCommands.set(name, {
command: cmd,
run: async (arg: CommandContext): Promise<any> => {
run: async (arg): Promise<any> => {
return await plugin.invoke(cmd.invoke, [arg]);
},
});
@ -137,8 +135,8 @@ export class Editor {
}
}
get currentNugget(): NuggetMeta | undefined {
return this.viewState.currentNugget;
get currentPage(): PageMeta | undefined {
return this.viewState.currentPage;
}
createEditorState(text: string): EditorState {
@ -152,7 +150,7 @@ export class Editor {
run: (): boolean => {
Promise.resolve()
.then(async () => {
await def.run(buildContext(def, this));
await def.run(null);
})
.catch((e) => console.error(e));
return true;
@ -173,7 +171,7 @@ export class Editor {
closeBrackets(),
autocompletion({
override: [
this.nuggetCompleter.bind(this),
this.pageCompleter.bind(this),
this.commandCompleter.bind(this),
],
}),
@ -232,7 +230,17 @@ export class Editor {
},
]),
EditorView.domEventHandlers({
click: this.click.bind(this),
click: (event: MouseEvent, view: EditorView) => {
safeRun(async () => {
let clickEvent: ClickEvent = {
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
altKey: event.altKey,
pos: view.posAtCoords(event)!,
};
await this.dispatchAppEvent("page:click", clickEvent);
});
},
}),
markdown({
base: customMarkDown,
@ -245,16 +253,16 @@ export class Editor {
});
}
nuggetCompleter(ctx: CompletionContext): CompletionResult | null {
pageCompleter(ctx: CompletionContext): CompletionResult | null {
let prefix = ctx.matchBefore(/\[\[[\w\s]*/);
if (!prefix) {
return null;
}
return {
from: prefix.from + 2,
options: this.viewState.allNuggets.map((nuggetMeta) => ({
label: nuggetMeta.name,
type: "nugget",
options: this.viewState.allPages.map((pageMeta) => ({
label: pageMeta.name,
type: "page",
})),
};
}
@ -281,7 +289,7 @@ export class Editor {
},
});
safeRun(async () => {
def.run(buildContext(def, this));
def.run(null);
});
},
});
@ -295,7 +303,7 @@ export class Editor {
update(value: null, transaction: Transaction): null {
if (transaction.docChanged) {
this.viewDispatch({
type: "nugget-updated",
type: "page-updated",
});
}
@ -303,91 +311,83 @@ export class Editor {
}
click(event: MouseEvent, view: EditorView) {
if (event.metaKey || event.ctrlKey) {
let coords = view.posAtCoords(event)!;
let node = syntaxTree(view.state).resolveInner(coords);
if (node && node.name === "WikiLinkPage") {
let nuggetName = view.state.sliceDoc(node.from, node.to);
this.navigate(nuggetName);
}
if (node && node.name === "TaskMarker") {
let checkBoxText = view.state.sliceDoc(node.from, node.to);
if (checkBoxText === "[x]" || checkBoxText === "[X]") {
view.dispatch({
changes: { from: node.from, to: node.to, insert: "[ ]" },
});
} else {
view.dispatch({
changes: { from: node.from, to: node.to, insert: "[x]" },
});
}
}
return false;
}
// if (event.metaKey || event.ctrlKey) {
// let coords = view.posAtCoords(event)!;
// let node = syntaxTree(view.state).resolveInner(coords);
// if (node && node.name === "WikiLinkPage") {
// let pageName = view.state.sliceDoc(node.from, node.to);
// this.navigate(pageName);
// }
// if (node && node.name === "TaskMarker") {
// let checkBoxText = view.state.sliceDoc(node.from, node.to);
// if (checkBoxText === "[x]" || checkBoxText === "[X]") {
// view.dispatch({
// changes: { from: node.from, to: node.to, insert: "[ ]" },
// });
// } else {
// view.dispatch({
// changes: { from: node.from, to: node.to, insert: "[x]" },
// });
// }
// }
// return false;
// }
}
async save() {
const editorState = this.editorView!.state;
if (!this.currentNugget) {
if (!this.currentPage) {
return;
}
// Write to file system
let nuggetMeta = await this.fs.writeNugget(
this.currentNugget.name,
let pageMeta = await this.fs.writePage(
this.currentPage.name,
editorState.sliceDoc()
);
// Update in open nugget cache
this.openNuggets.set(
this.currentNugget.name,
new NuggetState(
editorState,
this.editorView!.scrollDOM.scrollTop,
nuggetMeta
)
// Update in open page cache
this.openPages.set(
this.currentPage.name,
new PageState(editorState, this.editorView!.scrollDOM.scrollTop, pageMeta)
);
// Dispatch update to view
this.viewDispatch({ type: "nugget-saved", meta: nuggetMeta });
this.viewDispatch({ type: "page-saved", meta: pageMeta });
// If a new nugget was created, let's refresh the nugget list
if (nuggetMeta.created) {
await this.loadNuggetList();
// If a new page was created, let's refresh the page list
if (pageMeta.created) {
await this.loadPageList();
}
}
async loadNuggetList() {
let nuggetsMeta = await this.fs.listNuggets();
async loadPageList() {
let pagesMeta = await this.fs.listPages();
this.viewDispatch({
type: "nuggets-listed",
nuggets: nuggetsMeta,
type: "pages-listed",
pages: pagesMeta,
});
}
watch() {
setInterval(() => {
safeRun(async () => {
if (!this.currentNugget) {
if (!this.currentPage) {
return;
}
const currentNuggetName = this.currentNugget.name;
let newNuggetMeta = await this.fs.getMeta(currentNuggetName);
const currentPageName = this.currentPage.name;
let newPageMeta = await this.fs.getPageMeta(currentPageName);
if (
this.currentNugget.lastModified.getTime() <
newNuggetMeta.lastModified.getTime()
this.currentPage.lastModified.getTime() <
newPageMeta.lastModified.getTime()
) {
console.log("File changed on disk, reloading");
let nuggetData = await this.fs.readNugget(currentNuggetName);
this.openNuggets.set(
newNuggetMeta.name,
new NuggetState(
this.createEditorState(nuggetData.text),
0,
newNuggetMeta
)
let pageData = await this.fs.readPage(currentPageName);
this.openPages.set(
newPageMeta.name,
new PageState(this.createEditorState(pageData.text), 0, newPageMeta)
);
await this.loadNugget(currentNuggetName);
await this.loadPage(currentPageName);
}
});
}, watchInterval);
@ -405,37 +405,37 @@ export class Editor {
Promise.resolve()
.then(async () => {
await this.save();
const nuggetName = decodeURIComponent(location.hash.substring(1));
console.log("Now navigating to", nuggetName);
const pageName = decodeURIComponent(location.hash.substring(1));
console.log("Now navigating to", pageName);
if (!this.editorView) {
return;
}
await this.loadNugget(nuggetName);
await this.loadPage(pageName);
})
.catch((e) => {
console.error(e);
});
}
async loadNugget(nuggetName: string) {
let nuggetState = this.openNuggets.get(nuggetName);
if (!nuggetState) {
let nuggetData = await this.fs.readNugget(nuggetName);
nuggetState = new NuggetState(
this.createEditorState(nuggetData.text),
async loadPage(pageName: string) {
let pageState = this.openPages.get(pageName);
if (!pageState) {
let pageData = await this.fs.readPage(pageName);
pageState = new PageState(
this.createEditorState(pageData.text),
0,
nuggetData.meta
pageData.meta
);
this.openNuggets.set(nuggetName, nuggetState!);
this.openPages.set(pageName, pageState!);
}
this.editorView!.setState(nuggetState!.editorState);
this.editorView!.scrollDOM.scrollTop = nuggetState!.scrollTop;
this.editorView!.setState(pageState!.editorState);
this.editorView!.scrollDOM.scrollTop = pageState!.scrollTop;
this.viewDispatch({
type: "nugget-loaded",
meta: nuggetState.meta,
type: "page-loaded",
meta: pageState.meta,
});
}
@ -476,27 +476,27 @@ export class Editor {
let editor = this;
useEffect(() => {
if (viewState.currentNugget) {
document.title = viewState.currentNugget.name;
if (viewState.currentPage) {
document.title = viewState.currentPage.name;
}
}, [viewState.currentNugget]);
}, [viewState.currentPage]);
return (
<>
{viewState.showNuggetNavigator && (
<NuggetNavigator
allNuggets={viewState.allNuggets}
onNavigate={(nugget) => {
{viewState.showPageNavigator && (
<PageNavigator
allPages={viewState.allPages}
onNavigate={(page) => {
dispatch({ type: "stop-navigate" });
editor!.focus();
if (nugget) {
if (page) {
editor
?.save()
.then(() => {
editor!.navigate(nugget);
editor!.navigate(page);
})
.catch((e) => {
alert("Could not save nugget, not switching");
alert("Could not save page, not switching");
});
}
}}
@ -509,7 +509,7 @@ export class Editor {
editor!.focus();
if (cmd) {
safeRun(async () => {
let result = await cmd.run(buildContext(cmd, editor));
let result = await cmd.run(null);
console.log("Result of command", result);
});
}
@ -518,7 +518,7 @@ export class Editor {
/>
)}
<NavigationBar
currentNugget={viewState.currentNugget}
currentPage={viewState.currentPage}
onClick={() => {
dispatch({ type: "start-navigate" });
}}

View File

@ -1,4 +1,4 @@
// Nugget: this file is not built by Parcel, it's simply copied to the distribution
// Page: this file is not built by Parcel, it's simply copied to the distribution
// The reason is that somehow Parcel cannot accept using importScripts otherwise
function safeRun(fn) {
fn().catch((e) => {

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Nugget</title>
<title>Page</title>
<link rel="stylesheet" href="styles.css" />
<script type="module" src="boot.ts"></script>
<meta charset="UTF-8" />

View File

@ -21,11 +21,6 @@ export interface CommandDef {
// If to show in slash invoked menu and if so, with what label
// should match slashCommandRegexp
slashCommand?: string;
// Required context to be passed in as function arguments
requiredContext?: {
text?: boolean;
};
}
export interface FunctionDef {

View File

@ -6,19 +6,19 @@ export default function reducer(
): AppViewState {
console.log("Got action", action);
switch (action.type) {
case "nugget-loaded":
case "page-loaded":
return {
...state,
currentNugget: action.meta,
currentPage: action.meta,
isSaved: true,
};
case "nugget-saved":
case "page-saved":
return {
...state,
currentNugget: action.meta,
currentPage: action.meta,
isSaved: true,
};
case "nugget-updated":
case "page-updated":
// Minor rerender optimization, this is triggered a lot
if (!state.isSaved) {
return state;
@ -30,17 +30,17 @@ export default function reducer(
case "start-navigate":
return {
...state,
showNuggetNavigator: true,
showPageNavigator: true,
};
case "stop-navigate":
return {
...state,
showNuggetNavigator: false,
showPageNavigator: false,
};
case "nuggets-listed":
case "pages-listed":
return {
...state,
allNuggets: action.nuggets,
allPages: action.pages,
};
case "show-palette":
return {

View File

@ -1,18 +1,18 @@
import { NuggetMeta } from "./types";
import { PageMeta } from "./types";
export interface FileSystem {
listNuggets(): Promise<NuggetMeta[]>;
readNugget(name: string): Promise<{ text: string; meta: NuggetMeta }>;
writeNugget(name: string, text: string): Promise<NuggetMeta>;
getMeta(name: string): Promise<NuggetMeta>;
export interface Space {
listPages(): Promise<PageMeta[]>;
readPage(name: string): Promise<{ text: string; meta: PageMeta }>;
writePage(name: string, text: string): Promise<PageMeta>;
getPageMeta(name: string): Promise<PageMeta>;
}
export class HttpFileSystem implements FileSystem {
export class HttpRemoteSpace implements Space {
url: string;
constructor(url: string) {
this.url = url;
}
async listNuggets(): Promise<NuggetMeta[]> {
async listPages(): Promise<PageMeta[]> {
let req = await fetch(this.url, {
method: "GET",
});
@ -22,7 +22,7 @@ export class HttpFileSystem implements FileSystem {
lastModified: new Date(meta.lastModified),
}));
}
async readNugget(name: string): Promise<{ text: string; meta: NuggetMeta }> {
async readPage(name: string): Promise<{ text: string; meta: PageMeta }> {
let req = await fetch(`${this.url}/${name}`, {
method: "GET",
});
@ -34,12 +34,12 @@ export class HttpFileSystem implements FileSystem {
},
};
}
async writeNugget(name: string, text: string): Promise<NuggetMeta> {
async writePage(name: string, text: string): Promise<PageMeta> {
let req = await fetch(`${this.url}/${name}`, {
method: "PUT",
body: text,
});
// 201 (Created) means a new nugget was created
// 201 (Created) means a new page was created
return {
lastModified: new Date(+req.headers.get("Last-Modified")!),
name: name,
@ -47,7 +47,7 @@ export class HttpFileSystem implements FileSystem {
};
}
async getMeta(name: string): Promise<NuggetMeta> {
async getPageMeta(name: string): Promise<PageMeta> {
let req = await fetch(`${this.url}/${name}`, {
method: "OPTIONS",
});

View File

@ -164,7 +164,7 @@ body {
background-color: #ddd;
}
.current-nugget {
.current-page {
font-family: var(--editor-font);
margin-left: 10px;
margin-top: 10px;

View File

@ -69,4 +69,17 @@ export default (editor: Editor) => ({
}
}
},
"editor.getSyntaxNodeAtPos": (
ctx: SyscallContext,
pos: number
): { name: string; text: string } | undefined => {
const editorState = editor.editorView!.state;
let node = syntaxTree(editorState).resolveInner(pos);
if (node) {
return {
name: node.name,
text: editorState.sliceDoc(node.from, node.to),
};
}
},
});

View File

@ -1,46 +1,40 @@
import { CommandDef } from "./plugins/types";
export type NuggetMeta = {
export type PageMeta = {
name: string;
lastModified: Date;
created?: boolean;
};
export type CommandContext = {
text?: string;
};
export type AppCommand = {
command: CommandDef;
run: (ctx: CommandContext) => Promise<any>;
run: (arg: any) => Promise<any>;
};
export type AppViewState = {
currentNugget?: NuggetMeta;
currentPage?: PageMeta;
isSaved: boolean;
showNuggetNavigator: boolean;
showPageNavigator: boolean;
showCommandPalette: boolean;
allNuggets: NuggetMeta[];
allPages: PageMeta[];
commands: Map<string, AppCommand>;
};
export const initialViewState: AppViewState = {
isSaved: false,
showNuggetNavigator: false,
showPageNavigator: false,
showCommandPalette: false,
allNuggets: [],
allPages: [],
commands: new Map(),
};
export type Action =
| { type: "nugget-loaded"; meta: NuggetMeta }
| { type: "nugget-saved"; meta: NuggetMeta }
| { type: "nugget-updated" }
| { type: "nuggets-listed"; nuggets: NuggetMeta[] }
| { type: "page-loaded"; meta: PageMeta }
| { type: "page-saved"; meta: PageMeta }
| { type: "page-updated" }
| { type: "pages-listed"; pages: PageMeta[] }
| { type: "start-navigate" }
| { type: "stop-navigate" }
| { type: "update-commands"; commands: Map<string, AppCommand> }
| { type: "show-palette" }
| { type: "hide-palette" };
export type AppEvent = "ready" | "change" | "switch" | "click";