Whoops, forgot to commit this before

pull/774/head
Zef Hemel 2024-02-29 15:25:28 +01:00
parent e919aa82e9
commit ce5186c7c2
5 changed files with 690 additions and 0 deletions

21
plug-api/lib/json.test.ts Normal file
View File

@ -0,0 +1,21 @@
import { assertEquals } from "$lib/test_deps.ts";
import { deepEqual, deepObjectMerge, expandPropertyNames } from "./json.ts";
Deno.test("utils", () => {
assertEquals(deepEqual({ a: 1 }, { a: 1 }), true);
assertEquals(deepObjectMerge({ a: 1 }, { a: 2 }), { a: 2 });
assertEquals(
deepObjectMerge({ list: [1, 2, 3] }, { list: [4, 5, 6] }),
{ list: [1, 2, 3, 4, 5, 6] },
);
assertEquals(deepObjectMerge({ a: { b: 1 } }, { a: { c: 2 } }), {
a: { b: 1, c: 2 },
});
assertEquals(expandPropertyNames({ "a.b": 1 }), { a: { b: 1 } });
assertEquals(expandPropertyNames({ a: { "a.b": 1 } }), {
a: { a: { b: 1 } },
});
assertEquals(expandPropertyNames({ a: [{ "a.b": 1 }] }), {
a: [{ a: { b: 1 } }],
});
});

86
plug-api/lib/json.ts Normal file
View File

@ -0,0 +1,86 @@
// Compares two objects deeply
export function deepEqual(a: any, b: any): boolean {
if (a === b) {
return true;
}
if (typeof a !== typeof b) {
return false;
}
if (typeof a === "object") {
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) {
return false;
}
}
return true;
} else {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) {
return false;
}
for (const key of aKeys) {
if (!deepEqual(a[key], b[key])) {
return false;
}
}
return true;
}
}
return false;
}
// Expands property names in an object containing a .-separated path
export function expandPropertyNames(a: any): any {
if (!a) {
return a;
}
if (typeof a !== "object") {
return a;
}
if (Array.isArray(a)) {
return a.map(expandPropertyNames);
}
const expanded: any = {};
for (const key of Object.keys(a)) {
const parts = key.split(".");
let target = expanded;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!target[part]) {
target[part] = {};
}
target = target[part];
}
target[parts[parts.length - 1]] = expandPropertyNames(a[key]);
}
return expanded;
}
export function deepObjectMerge(a: any, b: any): any {
if (typeof a !== typeof b) {
return b;
}
if (typeof a === "object") {
if (Array.isArray(a) && Array.isArray(b)) {
return [...a, ...b];
} else {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
const merged = { ...a };
for (const key of bKeys) {
if (aKeys.includes(key)) {
merged[key] = deepObjectMerge(a[key], b[key]);
} else {
merged[key] = b[key];
}
}
return merged;
}
}
return b;
}

84
plug-api/lib/tree.test.ts Normal file
View File

@ -0,0 +1,84 @@
// import { parse } from "./parse_tree.ts";
import {
addParentPointers,
collectNodesMatching,
findParentMatching,
nodeAtPos,
parseTreeToAST,
removeParentPointers,
renderToText,
replaceNodesMatching,
} from "$sb/lib/tree.ts";
import { assertEquals, assertNotEquals } from "$lib/test_deps.ts";
import { parse } from "$common/markdown_parser/parse_tree.ts";
import { extendedMarkdownLanguage } from "$common/markdown_parser/parser.ts";
const mdTest1 = `
# Heading
## Sub _heading_ cool
Hello, this is some **bold** text and *italic*. And [a link](http://zef.me).
%% My comment here
%% And second line
And an @mention
http://zef.plus
- This is a list [[PageLink]]
- With another item
- TODOs:
- [ ] A task that's not yet done
- [x] Hello
- And a _third_ one [[Wiki Page]] yo
`;
const mdTest2 = `
Hello
* Item 1
*
Sup`;
const mdTest3 = `
\`\`\`yaml
name: something
\`\`\`
`;
Deno.test("Test parsing", () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
addParentPointers(mdTree);
// console.log(JSON.stringify(mdTree, null, 2));
const wikiLink = nodeAtPos(mdTree, mdTest1.indexOf("Wiki Page"))!;
assertEquals(wikiLink.type, "WikiLinkPage");
assertNotEquals(
findParentMatching(wikiLink, (n) => n.type === "BulletList"),
null,
);
const allTodos = collectNodesMatching(mdTree, (n) => n.type === "Task");
assertEquals(allTodos.length, 2);
// Render back into markdown should be equivalent
assertEquals(renderToText(mdTree), mdTest1);
removeParentPointers(mdTree);
replaceNodesMatching(mdTree, (n) => {
if (n.type === "Task") {
return {
type: "Tosk",
};
}
});
// console.log(JSON.stringify(mdTree, null, 2));
let mdTree3 = parse(extendedMarkdownLanguage, mdTest3);
// console.log(JSON.stringify(mdTree3, null, 2));
});
Deno.test("AST functions", () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
console.log(JSON.stringify(parseTreeToAST(mdTree), null, 2));
});

237
plug-api/lib/tree.ts Normal file
View File

@ -0,0 +1,237 @@
export type ParseTree = {
type?: string; // undefined === text node
from?: number;
to?: number;
text?: string;
children?: ParseTree[];
// Only present after running addParentPointers
parent?: ParseTree;
};
export type AST = [string, ...AST[]] | string;
export function addParentPointers(tree: ParseTree) {
if (!tree.children) {
return;
}
for (const child of tree.children) {
if (child.parent) {
// Already added parent pointers before
return;
}
child.parent = tree;
addParentPointers(child);
}
}
export function removeParentPointers(tree: ParseTree) {
delete tree.parent;
if (!tree.children) {
return;
}
for (const child of tree.children) {
removeParentPointers(child);
}
}
export function findParentMatching(
tree: ParseTree,
matchFn: (tree: ParseTree) => boolean,
): ParseTree | null {
let node = tree.parent;
while (node) {
if (matchFn(node)) {
return node;
}
node = node.parent;
}
return null;
}
export function collectNodesOfType(
tree: ParseTree,
nodeType: string,
): ParseTree[] {
return collectNodesMatching(tree, (n) => n.type === nodeType);
}
export function collectNodesMatching(
tree: ParseTree,
matchFn: (tree: ParseTree) => boolean,
): ParseTree[] {
if (matchFn(tree)) {
return [tree];
}
let results: ParseTree[] = [];
if (tree.children) {
for (const child of tree.children) {
results = [...results, ...collectNodesMatching(child, matchFn)];
}
}
return results;
}
export async function collectNodesMatchingAsync(
tree: ParseTree,
matchFn: (tree: ParseTree) => Promise<boolean>,
): Promise<ParseTree[]> {
if (await matchFn(tree)) {
return [tree];
}
let results: ParseTree[] = [];
if (tree.children) {
for (const child of tree.children) {
results = [
...results,
...await collectNodesMatchingAsync(child, matchFn),
];
}
}
return results;
}
// return value: returning undefined = not matched, continue, null = delete, new node = replace
export function replaceNodesMatching(
tree: ParseTree,
substituteFn: (tree: ParseTree) => ParseTree | null | undefined,
) {
if (tree.children) {
const children = tree.children.slice();
for (const child of children) {
const subst = substituteFn(child);
if (subst !== undefined) {
const pos = tree.children.indexOf(child);
if (subst) {
tree.children.splice(pos, 1, subst);
} else {
// null = delete
tree.children.splice(pos, 1);
}
} else {
replaceNodesMatching(child, substituteFn);
}
}
}
}
export async function replaceNodesMatchingAsync(
tree: ParseTree,
substituteFn: (tree: ParseTree) => Promise<ParseTree | null | undefined>,
) {
if (tree.children) {
const children = tree.children.slice();
for (const child of children) {
const subst = await substituteFn(child);
if (subst !== undefined) {
const pos = tree.children.indexOf(child);
if (subst) {
tree.children.splice(pos, 1, subst);
} else {
// null = delete
tree.children.splice(pos, 1);
}
} else {
await replaceNodesMatchingAsync(child, substituteFn);
}
}
}
}
export function findNodeMatching(
tree: ParseTree,
matchFn: (tree: ParseTree) => boolean,
): ParseTree | null {
return collectNodesMatching(tree, matchFn)[0];
}
export function findNodeOfType(
tree: ParseTree,
nodeType: string,
): ParseTree | null {
return collectNodesMatching(tree, (n) => n.type === nodeType)[0];
}
export function traverseTree(
tree: ParseTree,
// Return value = should stop traversal?
matchFn: (tree: ParseTree) => boolean,
): void {
// Do a collect, but ignore the result
collectNodesMatching(tree, matchFn);
}
export async function traverseTreeAsync(
tree: ParseTree,
// Return value = should stop traversal?
matchFn: (tree: ParseTree) => Promise<boolean>,
): Promise<void> {
// Do a collect, but ignore the result
await collectNodesMatchingAsync(tree, matchFn);
}
// Finds non-text node at position
export function nodeAtPos(tree: ParseTree, pos: number): ParseTree | null {
if (pos < tree.from! || pos >= tree.to!) {
return null;
}
if (!tree.children) {
return tree;
}
for (const child of tree.children) {
const n = nodeAtPos(child, pos);
if (n && n.text !== undefined) {
// Got a text node, let's return its parent
return tree;
} else if (n) {
// Got it
return n;
}
}
return null;
}
// Turn ParseTree back into text
export function renderToText(tree?: ParseTree): string {
if (!tree) {
return "";
}
const pieces: string[] = [];
if (tree.text !== undefined) {
return tree.text;
}
for (const child of tree.children!) {
pieces.push(renderToText(child));
}
return pieces.join("");
}
export function cloneTree(tree: ParseTree): ParseTree {
const newTree = { ...tree };
if (tree.children) {
newTree.children = tree.children.map(cloneTree);
}
delete newTree.parent;
return newTree;
}
export function parseTreeToAST(tree: ParseTree, omitTrimmable = true): AST {
const parseErrorNodes = collectNodesOfType(tree, "⚠");
if (parseErrorNodes.length > 0) {
throw new Error(
`Parse error in: ${renderToText(tree)}`,
);
}
if (tree.text !== undefined) {
return tree.text;
}
const ast: AST = [tree.type!];
for (const node of tree.children!) {
if (node.type && !node.type.endsWith("Mark")) {
ast.push(parseTreeToAST(node, omitTrimmable));
}
if (node.text && (omitTrimmable && node.text.trim() || !omitTrimmable)) {
ast.push(node.text);
}
}
return ast;
}

262
plug-api/types.ts Normal file
View File

@ -0,0 +1,262 @@
import type { ParseTree } from "./lib/tree.ts";
import type { TextChange } from "../web/change.ts";
export type FileMeta = {
name: string;
created: number;
lastModified: number;
contentType: string;
size: number;
perm: "ro" | "rw";
noSync?: boolean;
};
export type PageMeta = ObjectValue<
{
name: string;
created: string; // indexing it as a string
lastModified: string; // indexing it as a string
lastOpened?: number;
perm: "ro" | "rw";
} & Record<string, any>
>;
export type AttachmentMeta = {
name: string;
contentType: string;
created: number;
lastModified: number;
size: number;
perm: "ro" | "rw";
};
export type SyscallMeta = {
name: string;
requiredPermissions: string[];
argCount: number;
};
// Message Queue related types
export type MQMessage = {
id: string;
queue: string;
body: any;
retries?: number;
};
export type MQStats = {
queued: number;
processing: number;
dlq: number;
};
export type MQSubscribeOptions = {
batchSize?: number;
pollInterval?: number;
};
// Key-Value Store related types
export type KvKey = string[];
export type KV<T = any> = {
key: KvKey;
value: T;
};
export type OrderBy = {
expr: QueryExpression;
desc: boolean;
};
export type Select = {
name: string;
expr?: QueryExpression;
};
export type Query = {
querySource?: string;
filter?: QueryExpression;
orderBy?: OrderBy[];
select?: Select[];
limit?: QueryExpression;
render?: string;
renderAll?: boolean;
distinct?: boolean;
};
export type KvQuery = Omit<Query, "querySource"> & {
prefix?: KvKey;
};
export type QueryExpression =
| ["and", QueryExpression, QueryExpression]
| ["or", QueryExpression, QueryExpression]
| ["=", QueryExpression, QueryExpression]
| ["!=", QueryExpression, QueryExpression]
| ["=~", QueryExpression, QueryExpression]
| ["!=~", QueryExpression, QueryExpression]
| ["<", QueryExpression, QueryExpression]
| ["<=", QueryExpression, QueryExpression]
| [">", QueryExpression, QueryExpression]
| [">=", QueryExpression, QueryExpression]
| ["in", QueryExpression, QueryExpression]
| ["attr"] // .
| ["attr", string] // name
| ["attr", QueryExpression, string] // something.name
| ["global", string] // @name
| ["number", number]
| ["string", string]
| ["boolean", boolean]
| ["null"]
| ["not", QueryExpression]
| ["array", QueryExpression[]]
| ["object", [string, QueryExpression][]]
| ["regexp", string, string] // regex, modifier
| ["pageref", string]
| ["-", QueryExpression]
| ["+", QueryExpression, QueryExpression]
| ["-", QueryExpression, QueryExpression]
| ["*", QueryExpression, QueryExpression]
| ["%", QueryExpression, QueryExpression]
| ["/", QueryExpression, QueryExpression]
| ["?", QueryExpression, QueryExpression, QueryExpression]
| ["query", Query]
| ["call", string, QueryExpression[]];
export type FunctionMap = Record<
string,
(...args: any[]) => any
>;
/**
* An ObjectValue that can be indexed by the `index` plug, needs to have a minimum of
* of two fields:
* - ref: a unique reference (id) for the object, ideally a page reference
* - tags: a list of tags that the object belongs to
*/
export type ObjectValue<T> = {
ref: string;
tag: string; // main tag
tags?: string[];
itags?: string[]; // implicit or inherited tags (inherited from the page for instance)
} & T;
export type ObjectQuery = Omit<Query, "prefix">;
// Code widget stuff
export type CodeWidgetCallback = (
bodyText: string,
pageName: string,
) => Promise<CodeWidgetContent | null>;
export type CodeWidgetContent = {
html?: string;
markdown?: string;
script?: string;
buttons?: CodeWidgetButton[];
};
export type CodeWidgetButton = {
widgetTarget?: boolean;
description: string;
svg: string;
invokeFunction: string;
};
export type LintDiagnostic = {
from: number;
to: number;
severity: "error" | "warning" | "info" | "hint";
message: string;
};
export type UploadFile = {
name: string;
contentType: string;
content: Uint8Array;
};
export type AppEvent =
| "page:click"
| "editor:complete"
| "minieditor:complete"
| "slash:complete"
| "editor:lint"
| "page:load"
| "editor:init"
| "editor:pageLoaded" // args: pageName, previousPage, isSynced
| "editor:pageReloaded"
| "editor:pageSaved"
| "editor:modeswitch"
| "plugs:loaded"
| "editor:pageModified";
export type QueryProviderEvent = {
query: Query;
variables?: Record<string, any>;
};
export type ClickEvent = {
page: string;
pos: number;
metaKey: boolean;
ctrlKey: boolean;
altKey: boolean;
};
export type IndexEvent = {
name: string;
text: string;
};
export type IndexTreeEvent = {
name: string;
tree: ParseTree;
};
export type PublishEvent = {
uri?: string;
// Page name
name: string;
};
export type LintEvent = {
name: string;
tree: ParseTree;
};
export type CompleteEvent = {
pageName: string;
linePrefix: string;
pos: number;
parentNodes: string[];
};
export type SlashCompletionOption = {
label: string;
detail?: string;
invoke: string;
order?: number;
} & Record<string, any>;
export type SlashCompletions = {
// Ignore this one, only for compatibility with regular completions
from?: number;
// The actual completions
options: SlashCompletionOption[];
};
export type WidgetContent = {
html?: string;
script?: string;
markdown?: string;
url?: string;
height?: number;
width?: number;
};
/** PageModifiedEvent payload for "editor:pageModified". Fired when the document text changes
*/
export type PageModifiedEvent = {
changes: TextChange[];
};