From 052ded0f30f4b62824e87f7edab3a71b6c344f76 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 14 Jan 2023 17:14:43 +0000 Subject: [PATCH] Tidy up the interface-manager lint & doc. --- .../interface-manager/ApplicationCommand.ts | 80 -------------- .../interface-manager/CommandReader.ts | 2 +- .../interface-manager/DeadDocument.ts | 73 +++++++++---- .../interface-manager/DeadDocumentMarkdown.ts | 96 +---------------- .../interface-manager/DeadDocumentMatrix.ts | 22 +++- src/commands/interface-manager/JSXFactory.ts | 6 +- .../interface-manager/PagedDuplexStream.ts | 100 ++++++++++++++++++ 7 files changed, 179 insertions(+), 200 deletions(-) delete mode 100644 src/commands/interface-manager/ApplicationCommand.ts create mode 100644 src/commands/interface-manager/PagedDuplexStream.ts diff --git a/src/commands/interface-manager/ApplicationCommand.ts b/src/commands/interface-manager/ApplicationCommand.ts deleted file mode 100644 index 8cb6e545..00000000 --- a/src/commands/interface-manager/ApplicationCommand.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - * - * However, this file is modified and the modifications in this file - * are NOT distributed, contributed, committed, or licensed under the Apache License. - */ - -/** - * A feature that an application supports. - * Used by ApplicationCommands as required feature flags they depend on to function. - */ -export interface ApplicationFeature { - name: string, - description: string, -} - - -/** - * These are features that have been defined using `defineApplicationCommand`. - * you can access them using `getApplicationFeature`. - */ -const APPLICATION_FEATURES = new Map(); - -export function defineApplicationFeature(feature: ApplicationFeature): void { - if (APPLICATION_FEATURES.has(feature.name)) { - throw new TypeError(`Application feature has already been defined ${feature.name}`); - } - APPLICATION_FEATURES.set(feature.name, feature); -} - -export function getApplicationFeature(name: string): ApplicationFeature|undefined { - return APPLICATION_FEATURES.get(name); -} - -export class ApplicationCommand Promise> { - constructor( - public readonly requiredFeatures: ApplicationFeature[], - public readonly executor: ExecutorType - ) { - } -} - -export function defineApplicationCommand Promise>( - requiredFeatureNames: string[], - executor: ExecutorType) { - const features = requiredFeatureNames.map(name => { - const feature = getApplicationFeature(name); - if (feature) { - return feature - } else { - throw new TypeError(`Can't find a feature called ${name}`); - } - }) - return new ApplicationCommand(features, executor); -} - -defineApplicationFeature({ - name: "synapse admin", - description: "Requires that the mjolnir account has Synapse admin" -}); diff --git a/src/commands/interface-manager/CommandReader.ts b/src/commands/interface-manager/CommandReader.ts index bfad3cfb..9b7215a5 100644 --- a/src/commands/interface-manager/CommandReader.ts +++ b/src/commands/interface-manager/CommandReader.ts @@ -54,7 +54,7 @@ const WHITESPACE = [' ', '\r', '\f', '\v', '\n', '\t']; * It doesn't produce an AST because there isn't any syntax that can make a tree * just a list. * This allows commands to be dispatched based on `ReadItem`s and allows - * for more efficient (in terms of code) parsing of arguments, + * for more efficient (in terms of loc) parsing of arguments, * as I will demonstrate . * * The technique used is somewhat inefficient in terms of resources, diff --git a/src/commands/interface-manager/DeadDocument.ts b/src/commands/interface-manager/DeadDocument.ts index c12f6b97..bbc3fe04 100644 --- a/src/commands/interface-manager/DeadDocument.ts +++ b/src/commands/interface-manager/DeadDocument.ts @@ -5,6 +5,27 @@ import { SuperCoolStream } from "./CommandReader"; +/** + * The DeadDocument started as a universal document object model like Pandoc is. + * That kind of task is just too big for me though and someone else should have + * done it already. Irregardless, the way this is used is a simple DOM that can + * be used to incrementally render both HTML and Markdown. + * The reason we need to incrementally render both HTML and Markdown + * (which really means serialize, hence `DeadDocument`) is so that + * we can ensure in Matrix that both renditions of a node (html + markdown) + * are always in the same event and not split across multiple events. + * This ensures consistency when someone replies to an event that whichever + * format the client uses, the reply will be about the same "thing". + * So we have the power to split messages across multiple Matrix events + * automatically, without the need for micromanagement. + * + * While originally we were going to generate this DOM using a custom + * internal DSL, we discovered it was possible to use JSX templates with a + * custom DOM. You can find our own JSXFactory in `./JSXFactory.ts`. + * + * This means that end users shouldn't have to touch this DOM directly. + */ + export interface AbstractNode { readonly parent: DocumentNode|null; readonly leafNode: boolean; @@ -102,7 +123,7 @@ export interface InlineCodeNode extends LeafNode { export function addInlineCode(this: DocumentNode, data: string): InlineCodeNode { return this.addChild(makeLeafNode(NodeTag.InlineCode, this, data)); -} +} export interface PreformattedTextNode extends LeafNode { readonly tag: NodeTag.PreformattedText; @@ -155,7 +176,7 @@ function fringe(node: DocumentNode|LeafNode, flat: Flat = []): Flat { } } export type FringeLeafRenderFunction = (tag: NodeTag, node: LeafNode, context: Context) => void -export type FringeInnerRenderFunction = (type: FringeType, node: DocumentNode, context: Context, environment: TagDynamicEnvironment) => void; +export type FringeInnerRenderFunction = (type: FringeType, node: DocumentNode, context: Context, environment: TagDynamicEnvironment) => void; export interface FringeRenderer { getLeafRenderer(tag: NodeTag): FringeLeafRenderFunction @@ -200,7 +221,7 @@ export class SimpleFringeRenderer implements FringeRenderer { public registerRenderer|FringeLeafRenderFunction>(type: FringeType, tag: NodeTag, renderer: T): SimpleFringeRenderer { // The casting in here is evil. Not sure how to fix it. - switch(type) { + switch (type) { case FringeType.Pre: this.internRenderer(type, tag, this.preRenderers as Map, renderer); break; @@ -273,31 +294,32 @@ export class FringeWalker { this.dynamicEnvironment.pop(node.node); return node.node; } - while(this.stream.peekItem() && !COMMITTABLE_NODES.has(this.stream.peekItem().node.tag)) { - const node = this.stream.readItem(); - switch(node.type) { + while (this.stream.peekItem() && !COMMITTABLE_NODES.has(this.stream.peekItem().node.tag)) { + const annotatedNode = this.stream.readItem(); + switch (annotatedNode.type) { case FringeType.Pre: - renderInnerNode(node); + renderInnerNode(annotatedNode); break; case FringeType.Post: - postNode(node); + postNode(annotatedNode); break; case FringeType.Leaf: - if (node.node.leafNode !== true) { + if (annotatedNode.node.leafNode !== true) { throw new TypeError("Leaf nodes should not be marked as an inner node"); } - this.renderer.getLeafRenderer(node.node.tag)(node.node.tag, node.node as unknown as LeafNode, this.context); + this.renderer.getLeafRenderer(annotatedNode.node.tag) + (annotatedNode.node.tag, annotatedNode.node as unknown as LeafNode, this.context); break; default: - throw new TypeError(`Uknown fringe type ${node.type}`); + throw new TypeError(`Uknown fringe type ${annotatedNode.type}`); } } if (this.stream.peekItem() === undefined) { return undefined; } - const node = postNode(this.stream.readItem()); - this.commitHook(node, this.context); - return node; + const documentNode = postNode(this.stream.readItem()); + this.commitHook(documentNode, this.context); + return documentNode; } } @@ -311,11 +333,24 @@ export class TagDynamicEnvironmentEntry { } } -// OK we wrote this wrong -// TagDynamicEnvironmentEntries need to be associated with a node -// but not the variables themselves... -// otherwise how can a list item access the indentation width managed by a list or -// ordered list, all three of which have different tags +/** + * A dynamic environment is just an environment of bindings that is made + * by shadowing previous bindings and pushing and popping bindings "dynamically" + * for a given variable with some thing. + * + * In this example, we push and pop bindings with `DocumentNode`s. + * For example, if you make a binding to a variable called `indentationLevel` + * to set it to `1` from a `
    ` node, then this binding should be popped + * when the `FringeWalker` reaches the post node (`
`). + * Howerver, if we encounter another `
    `, we can read the existing value + * for `indentationLevel`, increment it and create a new binding + * that shadows the existing one. This too will get popped once we encounter + * the post node for this `
      ` node (`
    `). + * + * This makes it very easy to express a situation where you modify and + * restore variables that depend on node depth when walking the fringe, + * as the restoration of previous values can be handled automatically for us. + */ export class TagDynamicEnvironment { private readonly environments = new Map(); diff --git a/src/commands/interface-manager/DeadDocumentMarkdown.ts b/src/commands/interface-manager/DeadDocumentMarkdown.ts index 8a41d8d4..44965a96 100644 --- a/src/commands/interface-manager/DeadDocumentMarkdown.ts +++ b/src/commands/interface-manager/DeadDocumentMarkdown.ts @@ -4,101 +4,9 @@ */ import { DocumentNode, FringeInnerRenderFunction, FringeLeafRenderFunction, FringeType, LeafNode, NodeTag, SimpleFringeRenderer, TagDynamicEnvironment } from "./DeadDocument"; +import { PagedDuplexStream } from "./PagedDuplexStream"; -/** - * Ideally this would call a callback when a page is ready - * Unfortunatley there's no way to do that (and await) without making the stream - * all async. Which is annoying af. - * Therefore it's necessary for the stream to queue pages - */ -export class PagedDuplexStream { - private buffer: string = ''; - private pages: string[] = ['']; - - private lastCommittedNode?: DocumentNode; - constructor( - public readonly sizeLimit = 20_000, - ) { - } - - private get currentPage(): string { - return this.pages.at(this.pages.length - 1)!; - } - - private appendToCurrentPage(string: string) { - const currentIndex = this.pages.length - 1; - this.pages[currentIndex] = this.pages[currentIndex] + string; - } - - public writeString(string: string): PagedDuplexStream { - this.buffer += string; - return this; - } - - public getPosition(): number { - return this.buffer.length; - } - - public isPageAndBufferOverSize(): boolean { - return (this.currentPage.length + this.buffer.length) > this.sizeLimit; - } - - /** - * Creates a new page from the previously committed text - * @returns A page with all committed text. - */ - public ensureNewPage(): void { - if (this.currentPage.length !== 0) { - this.pages.push(''); - } - } - - /** - * Commit the buffered text to the current page. - * If the buffered text is over the `sizeLimit`, then the current - * page will be returned first, and then replaced with a new one in order - * to commit the buffer. - * @param node A DocumentNode to associate with the commit. - * @throws TypeError if the buffer is larger than the `sizeLimit`. - * @returns A page if the buffered text will force the current page to go over the size limit. - */ - public commit(node: DocumentNode): void { - if (this.isPageAndBufferOverSize()) { - if (this.currentPage.length === 0 && (this.buffer.length > this.sizeLimit)) { - throw new TypeError('Commit is too large, could not write a page for this commit'); - } - this.ensureNewPage(); - this.appendToCurrentPage(this.buffer); - this.lastCommittedNode = node; - } else { - this.appendToCurrentPage(this.buffer); - this.buffer = ''; - this.lastCommittedNode = node; - } - } - - public getLastCommittedNode(): DocumentNode|undefined { - return this.lastCommittedNode; - } - - public peekPage(): string|undefined { - // We consider a page "ready" when it is no longer the current page. - if (this.pages.length < 2) { - return undefined; - } - return this.pages.at(0); - } - - public readPage(): string|undefined { - // We consider a page "ready" when it is no longer the current page. - if (this.pages.length < 2) { - return undefined; - } - return this.pages.shift(); - } -} - -enum MarkdownVariables { +export enum MarkdownVariables { IndentationLevel = "indentation level" } diff --git a/src/commands/interface-manager/DeadDocumentMatrix.ts b/src/commands/interface-manager/DeadDocumentMatrix.ts index f7f6441f..bed28e8b 100644 --- a/src/commands/interface-manager/DeadDocumentMatrix.ts +++ b/src/commands/interface-manager/DeadDocumentMatrix.ts @@ -6,7 +6,8 @@ import { MatrixSendClient } from "../../MatrixEmitter"; import { AbstractNode, DocumentNode, FringeWalker } from "./DeadDocument"; import { HTML_RENDERER } from "./DeadDocumentHtml"; -import { MARKDOWN_RENDERER, PagedDuplexStream } from "./DeadDocumentMarkdown"; +import { MARKDOWN_RENDERER } from "./DeadDocumentMarkdown"; +import { PagedDuplexStream } from "./PagedDuplexStream"; function checkEqual(node1: AbstractNode|undefined, node2: AbstractNode|undefined): true { if (!Object.is(node1, node2)) { @@ -17,9 +18,17 @@ function checkEqual(node1: AbstractNode|undefined, node2: AbstractNode|undefined export type SendMatrixEventCB = (text: string, html: string) => Promise; +/** + * Render the `DocumentNode` to Matrix (in both HTML + Markdown) using the + * callback provided to send each event. Should serialized content span + * more than one event, then the callback will be called for each event. + * @param node A document node to render to Matrix. + * @param cb A callback that will send the text+html for a single event + * to a Matrix room. + */ export async function renderMatrix(node: DocumentNode, cb: SendMatrixEventCB) { - const commitHook = (node: DocumentNode, context: { output: PagedDuplexStream }) => { - context.output.commit(node); + const commitHook = (commitNode: DocumentNode, context: { output: PagedDuplexStream }) => { + context.output.commit(commitNode); }; const markdownOutput = new PagedDuplexStream(); const markdownWalker = new FringeWalker( @@ -60,6 +69,13 @@ export async function renderMatrix(node: DocumentNode, cb: SendMatrixEventCB) { } } +/** + * Render the document node to html+text `m.notice` events. + * @param node The document node to render. + * @param roomId The room to send the events to. + * @param event An event to reply to. + * @param client A MatrixClient to send the events with. + */ export async function renderMatrixAndSend(node: DocumentNode, roomId: string, event: any, client: MatrixSendClient): Promise { // We desperatley need support for threads to make this work in a non-shit way. await renderMatrix(node, async (text: string, html: string) => { diff --git a/src/commands/interface-manager/JSXFactory.ts b/src/commands/interface-manager/JSXFactory.ts index a1c54cba..671de957 100644 --- a/src/commands/interface-manager/JSXFactory.ts +++ b/src/commands/interface-manager/JSXFactory.ts @@ -16,7 +16,7 @@ export function JSXFactory(tag: NodeTag, properties: any, ...rawChildren: (Docum makeLeafNode(NodeTag.TextNode, node, (rawChild as number).toString()); } else if (Array.isArray(rawChild)) { rawChild.forEach(ensureChild); - } else if (typeof rawChild.leafNode === 'boolean'){ + } else if (typeof rawChild.leafNode === 'boolean') { node.addChild(rawChild); } else { throw new TypeError(`Unexpected raw child ${JSON.stringify(rawChild)}`) @@ -30,7 +30,7 @@ export function JSXFactory(tag: NodeTag, properties: any, ...rawChildren: (Docum namespace JSXFactory { export interface IntrinsicElements { [elemName: string]: any; - } + } } /** @@ -42,7 +42,7 @@ declare global { export namespace JSX { export interface IntrinsicElements { [elemName: string]: any; - } + } } } diff --git a/src/commands/interface-manager/PagedDuplexStream.ts b/src/commands/interface-manager/PagedDuplexStream.ts new file mode 100644 index 00000000..8f546e0d --- /dev/null +++ b/src/commands/interface-manager/PagedDuplexStream.ts @@ -0,0 +1,100 @@ +/** + * Copyright (C) 2023 Gnuxie + * All rights reserved. + */ + +import { DocumentNode } from "./DeadDocument"; + +/** + * Ideally this would call a callback when a page is ready + * Unfortunatley there's no way to do that (and await) without making the stream + * all async. Which is annoying af. + * Therefore it's necessary for the stream to queue pages + */ + +export class PagedDuplexStream { + private buffer = ''; + private pages: string[] = ['']; + + private lastCommittedNode?: DocumentNode; + constructor( + public readonly sizeLimit = 20000 + ) { + } + + private get currentPage(): string { + return this.pages.at(this.pages.length - 1)!; + } + + private appendToCurrentPage(string: string) { + const currentIndex = this.pages.length - 1; + this.pages[currentIndex] = this.pages[currentIndex] + string; + } + + public writeString(string: string): PagedDuplexStream { + this.buffer += string; + return this; + } + + public getPosition(): number { + return this.buffer.length; + } + + public isPageAndBufferOverSize(): boolean { + return (this.currentPage.length + this.buffer.length) > this.sizeLimit; + } + + /** + * Creates a new page from the previously committed text + * @returns A page with all committed text. + */ + public ensureNewPage(): void { + if (this.currentPage.length !== 0) { + this.pages.push(''); + } + } + + /** + * Commit the buffered text to the current page. + * If the buffered text is over the `sizeLimit`, then the current + * page will be returned first, and then replaced with a new one in order + * to commit the buffer. + * @param node A DocumentNode to associate with the commit. + * @throws TypeError if the buffer is larger than the `sizeLimit`. + * @returns A page if the buffered text will force the current page to go over the size limit. + */ + public commit(node: DocumentNode): void { + if (this.isPageAndBufferOverSize()) { + if (this.currentPage.length === 0 && (this.buffer.length > this.sizeLimit)) { + throw new TypeError('Commit is too large, could not write a page for this commit'); + } + this.ensureNewPage(); + this.appendToCurrentPage(this.buffer); + this.lastCommittedNode = node; + } else { + this.appendToCurrentPage(this.buffer); + this.buffer = ''; + this.lastCommittedNode = node; + } + } + + public getLastCommittedNode(): DocumentNode | undefined { + return this.lastCommittedNode; + } + + public peekPage(): string | undefined { + // We consider a page "ready" when it is no longer the current page. + if (this.pages.length < 2) { + return undefined; + } + return this.pages.at(0); + } + + public readPage(): string | undefined { + // We consider a page "ready" when it is no longer the current page. + if (this.pages.length < 2) { + return undefined; + } + return this.pages.shift(); + } +}