Avatar

xkonti

22 public vals
Joined August 11, 2023

Allows to publish a ntfy notification using a fluent builder configuration.

Usage example

Create valimport { ntfy } from "https://esm.town/v/xkonti/ntfy"; await ntfy() .toServer(Deno.env.get("ntfyServer")) .asUser(Deno.env.get("ntfyUser"), Deno.env.get("ntfyPassword")) .toTopic("testing") .withMessage("Hello there!") .withTitle("First test") .withViewAction("My website", "https://xkonti.tech") .withTags("package", "val-town") .withPriority("high") .publish();

⚠️ For the notification to be sent it needs to be published (publish function).

Use helper

Executes specified functions that can modify the notification. Can be used to streamline authentication, apply common operations, etc.

Create valimport { ntfy } from "https://esm.town/v/xkonti/ntfy"; const toMyNtfyServer = (builder: ReturnType<typeof ntfy>) => { builder .toServer(Deno.env.get("ntfyServer")) .asUser(Deno.env.get("ntfyUser"), Deno.env.get("ntfyPassword")); }; await ntfy() .use(toMyNtfyServer) .toTopic('home-automation') .withMessage('You left the front door open') .publish();

You can pass it multiple functions.

Functions

toServer(url) - optional

Specifies a server that the notification will be sent do. By default it's https://ntfy.sh.

asUser(user, password) - optional

Authenticates with the user and password. Please use ValTown's secrets for this.

Create valawait ntfy() .asUser('user123', '12345') ...

usingToken(token) - optional

Authenticates using the provided token. Please use ValTown's secrets for this.

Create valawait ntfy() .usingToken('some-token') ...

toTopic(topic) - required

Specifies which topic to publish the message to.

Create valawait ntfy() .toTopic('home-automation') ...

withMessage(message, markdown) - required

Specifies the main message of the notification. You can also flag it as markdown by passing true as a second argument. By default markdown is false.

Create valawait ntfy() .toTopic('home-automation') .withMessage('You left the front door open') ...
Create valawait ntfy() .toTopic('home-automation') .withMessage('Your garage is **flooding**!', true) ...

withTitle(title) - optional

Sets the title of the notification.

Create valawait ntfy() .toTopic('home-automation') .withTitle('Garage') .withMessage('You left the front door open') ...

withPriority(priority) - optional

Sets the priority of the notification. Possible from lowest to highest priority: min, low, default, high, max

Create valawait ntfy() .toTopic('home-automation') .withMessage('You left the front door open') .withPriority('high') ...

Alternatively you can use dedicated functions: .withMinPriority(), .withLowPriority(), .withDefaultPriority(), .withHighPriority(), .withMaxPriority()

Create valawait ntfy() .toTopic('home-automation') .withMessage('You left the front door open') .withHighPriority() ...

withTags(...tags) - optional

Sets tags of the notification. This overrides any previously existing tags.

Create valawait ntfy() .toTopic('home-automation') .withMessage('You left the front door open') .withTags('door', 'safety') ...

withDelay(delay) - optional

Sets the delay for notification delivery. Read ntfy docs for more info.

Create valawait ntfy() .toTopic('home-automation') .withMessage('You left the front door open') .withDelay('tomorrow, 10am') ...

withViewAction(label, url, clear?) - optional

Adds an action button that opens a website or app when tapped.

  • label - Label of the action button in the notification
  • url - URL to open when action is tapped
  • clear - Clear notification after action button is tapped (defaults to false)
Create valawait ntfy() .toTopic('home-automation') .withMessage('You left the front door open') .withViewAction('View Val', 'https://www.val.town/v/xkonti/ntfy') ...

withBroadcastAction(label, intent?, extras?, clear?) - optional

Adds an action button that sends an Android broadcast intent when tapped.

  • label - Label of the action button in the notification
  • intent - Android intent name, default is io.heckel.ntfy.USER_ACTION
  • extras - Android intent extras.
  • clear - Clear notification after action button is tapped (defaults to false)
Create valawait ntfy() .toTopic('home-automation') .withMessage('You left the front door open') .withBroadcastAction('Selfie', 'Take picture', { 'cmd': 'pic' }) ...

withHtmlAction(label, url, method?, headers?, body?, clear?) - optional

Adds an action button that sends a HTTP request when tapped.

  • label - Label of the action button in the notification
  • url - URL to which the HTTP request will be sent
  • method - HTTP method to use for request, default is POST
  • headers - HTTP headers to pass in request.
  • body - HTTP body as a string
  • clear - Clear notification after action button is tapped (defaults to false)
Create valawait ntfy() .toTopic('home-automation') .withMessage('You left the front door open') .withHtmlAction( 'Self-destruct', 'https://self.destruct/initiate', 'POST', { 'Authentication': 'Bearer 123' }, '{"countdown":60}' ) ...

withClickUrl(url) - optional

Makes the notification open the specified URL when clicked (tapped).

withRawAttachment(filename, filedata) - optional

Attached a file to the notification. Only one file can be attached.

Create valawait ntfy() .toTopic('home-automation') .withMessage('You left the front door open') .withRawAttachment('todo.txt', 'Nothing!') ...

withUrlAttachment(url) - optional

Attaches a file that is hosted elsewhere (URL).

withIcon(url) - optional

Sets an icon for the notification.

viaEmail(email) - optional

Sends the notification via email instead.

viaPhoneCall(number) - optional

Sends the notification via a phone call. The number defaults to yes, which makes it use the first phone number defined on your ntfy account.

withoutCache() - optional

Disables the cache for the notification. Read the docs on caching for more info.

withoutFirebase() - optional

Disables Firebase forwarding for the notification. Read the docs on Firebase for more info.

withUnifiedPush() - optional

Indicates intent of using the Unified Push for the notification. Read the docs on Unified Push for more info.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
export const ntfy = () => {
class NtfyPublisher {
private serverUrl: string = "ntfy.sh";
private topic: string | null = null;
private message: string | null = null;
private file: any | null = null;
private actions: string[] = [];
private headers: Record<string, string> = {};
toServer(url: string): NtfyPublisher {
this.serverUrl = url;
return this;
}
asUser(user: string, password: string) {
const payload = btoa(`${user}:${password}`);
this.headers["Authorization"] = `Basic ${payload}`;
return this;
}
usingToken(token: string) {
this.headers["Authorization"] = `Bearer ${token}`;
return this;
}
toTopic(topic: string) {
this.topic = topic;
return this;
}
withMessage(message: string, markdown = false) {
if (this.file != null)
throw new Error(
"A file is already attached. Can't send a message and a file at once.",
);
if (this.message != null)
throw new Error(
"A message has been already set. Can't send 2 messages at once.",
);
this.message = message;
if (markdown) {
this.headers["Markdown"] = "yes";
}
return this;
}
withTitle(title: string) {
this.headers["Title"] = title;
return this;
}
withPriority(priority: "min" | "low" | "default" | "high" | "max") {
this.headers["Priority"] = priority;
return this;
}
withMinPriority() {
return this.withPriority("min");
}
withLowPriority() {
return this.withPriority("low");
}
withDefaultPriority() {
return this.withPriority("default");
}
withHighPriority() {
return this.withPriority("high");
}
withMaxPriority() {
return this.withPriority("max");
}
withTags(...tags: string[]) {
this.headers["Tags"] = tags.join(",");
return this;
}
withDelay(when: string) {
this.headers["Delay"] = when;
return this;
}
withViewAction(label: string, url: string, clear: string | null = null) {
if (this.actions.length >= 3) {
throw new Error("Max of 3 actions are allowed.");
}
this.actions.push(
`view, ${label}, ${url}${clear == null ? "" : ", clear=" + clear}`,
);
return this;
}
withBroadcastAction(
label: string,
intent: string | null = null,
extras: Record<string, string> = {},
clear: boolean | null = null,
) {
if (this.actions.length >= 3) {
throw new Error("Max of 3 actions are allowed.");
}
const segments = ["broadcast", label];
if (intent != null) {
segments.push(`intent=${intent}`);
}
if (clear != null) {
segments.push(`clear=${clear}`);
}
for (const [key, value] of Object.entries(extras)) {
segments.push(`extras.${key}=${value}`);
}
this.actions.push(segments.join(", "));

A simple Rest API that allows for you GPT to save and recall snippets of data (memories). You can read my blog post explaining it in detail here: xkonti.tech

Demonstration

First conversation:

FirstConversation.png

What GPT sent do the API:

{
  "name": "Life Essence and Biological Processes",
  "description": "Explore the role of life essence in enabling biological processes, with a focus on how dimensional interference, particularly from the Incorporeal dimension, facilitates the existence of these processes. This exploration should delve into the complex interplay between the Physical and Incorporeal dimensions, examining how life essence acts as a crucial element in the emergence and sustenance of life in the Physical dimension.",
  "summary": "Expanding on the role of life essence in biological processes, emphasizing the interaction between the Physical and Incorporeal dimensions.",
  "reason": "To deepen the understanding of how life essence, influenced by dimensional interference, is essential for biological processes in the novel's universe."
}

Separate conversation somewhere in the future:

Second Conversation.png

Setup

There are several steps to set up the API:

  • deploy and configure the API
  • create the API key for your GPT
  • add an action for the API in you GPT
  • add prompt section to your GPT so that it can use it properly

Deploying the API on Val Town

Deploy your own memory API. You can fork the following Val to do it: https://www.val.town/v/xkonti/memoryApiExample

In the code configure the appropriate values:

  • apiName the name of your API - used in the Privacy Policy (eg. Memory API)
  • contactEmail - the email to provide for contact in the Privacy Policy (eg. some@email.com)
  • lastPolicyUpdate - the date the Privacy Policy was last updated (eg. 2023-11-28)
  • blobKeyPrefix - the prefix for the blob storage keys used by your API - more info below (eg. gpt:memories:)
  • apiKeyPrefix - the prefix for you API Keys secrets - more info below (eg. GPTMEMORYAPI_KEY_)

Create API keys

The Memory API is designed to serve multiple GPTs at the same time. Each GPT should have it's own unique name and API key.

The name is used for identifying the specific GPT and appended to both:

  • blobKeyPrefix- to maintain separate memory storage from other GPTs
  • apiKeyPrefix - to maintain separate API key for each GPT
  1. Please pick a unique alphanumeric name for your GPT. For example personaltrainer.
  2. Generate some alphanumeric API key for your GPT. For example Wrangle-Chapped-Monkhood4-Domain-Suspend
  3. Add a new secret to your Val.town secrets storage. The Key should be the picked name prefixed by apiKeyPrefix. Using the default it would be GPTMEMORYAPI_KEY_personaltrainer. The value of the secret should be the API key itself.

The memories of the GPT will be stored in the blob storage under the key blobKeyPrefix + name, for example: gpt:memories:personaltrainer.

Adding GPT action

  1. Add a new action in your GPT.
  2. Get the OpenAPI spefication by calling the /openapi endpoint of your API
  3. Change all <APIURL> instances within the specification to the url of your deployed API. For example https://xkonti-memoryapiexample.web.val.run
  4. Set the authentication method to basic and provide a base64 encoded version of the <name>:<apiKey>. For example: personaltrainer:Wrangle-Chapped-Monkhood4-Domain-Suspend -> cGVyc29uYWx0cmFpbmVyOldyYW5nbGUtQ2hhcHBlZC1Nb25raG9vZDQtRG9tYWluLVN1c3BlbmQ=
  5. Add the link to the privacy policy, which is the /privacy endpoint of your API. For example: https://xkonti-memoryapiexample.web.val.run/privacy

Adding the prompt section

To make your GPT understand the usage of your new action you should include usage instruction in your prompt. Here's an example of such instructions section:

# Long-term memory

At some point the user might ask you to do something with "memory". Things like "remember", "save to memory", "forget", "update memory", etc. Please use corresponding actions to achieve those tasks. User might also ask you to perform some task with the context of your "memory" - in that case fetch all memories before proceeding with the task. The memories should be formed in a clear and purely informative language, void of unnecessary adjectives or decorative language forms. An exception to that rule might be a case when the language itself is the integral part of information (snippet of writing to remember for later, examples of some specific language forms, quotes, etc.).

Structure of a memory:
- name - a simple name of the memory to give it context at a glance
- description - a detailed description of the thing that should be remembered. There is no length limit.
- summary - a short summary of the memory. This should be formed in a way that will allow for ease of understanding which memories to retrieve in full detail just by reading the list of summaries. If there are some simple facts that have to be remembered and are the main point of the memory they should be included in the summary. The summary should also be written in a compressed way with all unnecessary words skipped if possible (more like a set of keywords or a Google Search input).
- reason - the reason for the remembering - this should give extra information about the situation in which the memory was requested to be saved.

The memory accessed through those actions is a long-term memory persistent between various conversations with the user. You can assume that there already are many memories available for retrieval.

In some situations you might want to save some information to your memory for future recall. Do it in situations where you expect that some important details of the conversation might be lost and should be preserved.

Analogously you can retrieve memories at any point if the task at hand suggests the need or there isn't much information about the subject in your knowledge base.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import * as uuid from "https://deno.land/std/uuid/mod.ts";
import { blob } from "https://esm.town/v/std/blob";
import { getPolicy } from "https://esm.town/v/xkonti/memoryApiPolicy";
import { Hono } from "npm:hono@3";
export const handleMemoryApiRequest = async (
req: Request,
apiName: string,
contactEmail: string,
lastPolicyUpdate: string,
blobKeyPrefix: string,
apiKeyPrefix: string,
) => {
// ==== HELPERS ====
const getMemoriesKey = (key: string): string => {
return `${blobKeyPrefix}${key}`;
};
const getMemories = async (key: string) => {
return await blob.getJSON(getMemoriesKey(key), []);
};
const setMemories = async (memories: any, key: string) => {
await blob.setJSON(getMemoriesKey(key), memories);
};
const verifyRequest = (c): { memoriesKey: string; error: any } => {
// Verify API key coming as a Bearer header
const authHeader = c.req.headers.get("Authorization");
if (!authHeader || !authHeader.startsWith("Basic ")) {
console.error("Missing or invalid authorization header");
return { memoriesKey: "", error: c.text("Unauthorized", 401) };
}
// Extract and decode the base64 encoded credentials
const base64Credentials = authHeader.split(" ")[1];
const credentials = atob(base64Credentials);
// Split into user and password
const [key, token] = credentials.split(":");
if (key == null || key === "") {
console.error("No memory key in authorization header");
return { memoriesKey: "", error: c.text("Forbidden", 403) };
}
const expectedKey = Deno.env.get(apiKeyPrefix + key) ?? null;
if (token !== expectedKey) {
console.error("Invalid API KEY header");
return { memoriesKey: "", error: c.text("Forbidden", 403) };
}
return { memoriesKey: key, error: null };
};
// API
const app = new Hono();
// GET ALL MEMORIES
app.get("/memory", async (c) => {
const { memoriesKey, error } = verifyRequest(c);
if (error != null) return error;
// Get all memories and filter out detailed descriptions
const memories = (await getMemories(memoriesKey))
.map(memory => ({
id: memory.id,
name: memory.name,
summary: memory.summary ?? "Missing summary",
reason: memory.reason,
}));
return c.json({
memories,
});
});
// GET SPECIFIC MEMORIES BY IDS
app.get("/memory/specific", async (c) => {
const { memoriesKey, error } = verifyRequest(c);
if (error != null) return error;
// Extract the IDs from the query parameter
const idsQuery = c.req.query("ids");
if (!idsQuery) {
return c.text("Bad Request: No IDs provided", 400);
}
// Split the IDs and validate them
const ids = idsQuery.split(",");
if (ids.length === 0) {
return c.text("Bad Request: Invalid IDs format", 400);
}
// Get the current list of Memories
const memories = await getMemories(memoriesKey);
// Filter for memories with the given IDs
const requestedMemories = memories.filter(memory => ids.includes(memory.id));
// Check if any memories were found
if (requestedMemories.length === 0) {
return c.text("No memories found with the provided IDs", 404);
1
export type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
import { HttpVerb } from "https://esm.town/v/xkonti/httpUtils";
import { z } from "npm:zod";
import { zodToJsonSchema } from "npm:zod-to-json-schema";
export function getSchemaDesc(schema: z.Schema) {
return zodToJsonSchema(schema, {
name: "schema",
target: "openApi3",
}).definitions.schema;
}
export interface EndpointDefinition {
verb: HttpVerb;
path: string;
requestSchema: z.Schema | null;
requestDesc: string | null;
responseSchema: z.Schema | null;
responseDesc: string | null;
desc: string;
operationId: string;
}
export function getPathsDesc(endpoints: EndpointDefinition[]) {
const paths: any = {};
for (const endpoint of endpoints) {
// This is to allow multiple endpoints with the same path but different verbs
if (!paths[endpoint.path]) {
paths[endpoint.path] = {};
}
paths[endpoint.path][endpoint.verb.toLowerCase()] = {
description: endpoint.desc,
operationId: endpoint.operationId,
requestBody: endpoint.requestSchema
? {
description: endpoint.requestDesc ?? "Request body",
required: true,
content: {
"application/json": {
schema: getSchemaDesc(endpoint.requestSchema),
},
},
}
: undefined,
responses: {
"200": {
description: endpoint.responseDesc ?? "Success",
content: {
"application/json": {
schema: getSchemaDesc(endpoint.responseSchema),
},
},
},
},
};
}
return paths;
}
export function getOpenApiSpec(
url: string,
title: string,
description: string,
version: string,
endpoints: EndpointDefinition[],
) {
return {
openapi: "3.1.0",
info: {
title,
description,
version,
},
servers: [
{
url,
},
],
paths: getPathsDesc(endpoints),
};
}

Allows for automatic generation of Hono API comatible with GPTs. Endpoints' inputs and outputs need to be specified via types from which the Open API spec is generated automatically and available via /gpt/schema endpoint.

Usage example:

Create valimport { GptApi } from "https://esm.town/v/xkonti/gptApiFramework"; import { z } from "npm:zod"; /** * COMMON TYPES */ const ResponseCommandSchema = z.object({ feedback: z.string().describe("Feedback regarding submitted action"), command: z.string().describe("The command for the Mediator AI to follow strictly"), data: z.string().optional().describe("Additional data related to the given command"), }).describe("Contains feedback and further instructions to follow"); export type ResponseCommand = z.infer<typeof ResponseCommandSchema>; /** * INITIALIZE API */ const api = new GptApi({ url: "https://xkonti-planoverseerai.web.val.run", title: "Overseer AI API", description: "The API for interacting with the Overseer AI", version: "1.0.0", }); /** * REQUIREMENTS GATHERING ENDPOINTS */ api.nothingToJson<ResponseCommand>({ verb: "POST", path: "/newproblem", operationId: "new-problem", desc: "Endpoint for informing Overseer AI about a new problem presented by the User", requestSchema: null, requestDesc: null, responseSchema: ResponseCommandSchema, responseDesc: "Instruction on how to proceed with the new problem", }, async (ctx) => { return { feedback: "User input downloaded. Problem analysis is required.", command: await getPrompt("analyze-problem"), data: "", }; }); export default api.serve();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { EndpointDefinition, getOpenApiSpec } from "https://esm.town/v/xkonti/gptApiSchemaBuilder";
import { HttpVerb } from "https://esm.town/v/xkonti/httpUtils";
import { Hono } from "npm:hono@3";
import { z } from "npm:zod";
export interface ApiInfo {
url: string;
title: string;
description: string;
version: string;
}
export class GptApi {
app = new Hono();
info: ApiInfo;
endpoints: EndpointDefinition[] = [];
constructor(info: ApiInfo) {
this.info = info;
this.app.get("/gpt/schema", async (ctx) => {
const spec = getOpenApiSpec(
this.info.url,
this.info.title,
this.info.description,
this.info.version,
this.endpoints,
);
return ctx.text(JSON.stringify(spec, null, 2));
});
}
/**
* Register endpoint that ignores all inputs and returns a specific JSON response.
*/
nothingToJson<TResponse>(
endpointDef: EndpointDefinition,
handler: (ctx) => Promise<TResponse>,
) {
endpointDef.requestDesc = null;
endpointDef.requestSchema = null;
const handlerWrapper = async (ctx) => {
const response = await handler(ctx);
return ctx.json(response);
};
// TODO: Verify response stuff is in place
this.registerHandler(endpointDef, handlerWrapper);
}
/**
* Register endpoint that accepts a JSON DTO and returns a specific JSON response.
*/
jsonToJson<TRequest, TResponse>(
endpointDef: EndpointDefinition,
handler: (ctx, dto: TRequest) => Promise<TResponse>,
) {
// TODO: Verify request and response is in place
const handlerWrapper = async (ctx) => {
const data = await ctx.req.json() as TRequest;
// TODO: Handle invalid data format
const response = await handler(ctx, data);
return ctx.json(response);
};
this.registerHandler(endpointDef, handlerWrapper);
}
// Registers a handler for a verb + path combo.
registerHandler(
endpointDef: EndpointDefinition,
handler: (ctx) => any,
) {
this.endpoints.push(endpointDef);
const verb = endpointDef.verb;
const path = endpointDef.path;
switch (verb) {
case "GET":
this.app.get(path, handler);
break;
case "POST":
this.app.post(path, handler);
break;
case "PUT":
this.app.put(path, handler);
break;
case "DELETE":
this.app.delete(path, handler);
break;
case "PATCH":
this.app.patch(path, handler);
break;
default:
throw new Error(`HTTP verb ${verb} not supported`);
}
}
/**
* Usage: `export default gptApi.serve();`
1
2
3
4
5
6
7
8
import { Page } from "https://esm.town/v/xkonti/htmlBuilder";
export function addMilligram(page: Page): Page {
return page
.addStylesheet("https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic")
.addStylesheet("https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css")
.addStylesheet("https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { basicTag, selfClosingTag, Tag } from "https://esm.town/v/xkonti/htmlBuilder";
import { HtmlContents, TagDescriptor } from "https://esm.town/v/xkonti/htmlInterfaces";
// CONTENT SECTIONING
export function addressTag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("address", content);
}
export function articleTag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("article", content);
}
export function asideTag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("aside", content);
}
export function footerTag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("footer", content);
}
export function headerTag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("header", content);
}
export function h1Tag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("h1", content);
}
export function h2Tag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("h2", content);
}
export function h3Tag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("h3", content);
}
export function h4Tag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("h4", content);
}
export function h5Tag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("h5", content);
}
export function h6Tag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("h6", content);
}
export function hgroupTag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("hgroup", content);
}
export function mainTag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("main", content);
}
export function navTag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("nav", content);
}
export function sectionTag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("section", content);
}
export function searchTag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("search", content);
}
// TEXT CONTENT
export function blockquoteTag(cite: string | undefined, content: HtmlContents | undefined = undefined): Tag {
return basicTag("blockquote", content)
.setAttribute("cite", cite);
}
export function ddTag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("dd", content);
}
export function divTag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("div", content);
}
export function dtTag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("dt", content);
}
export function figcaptionTag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("figcaption", content);
}
export function figureTag(content: HtmlContents | undefined = undefined): Tag {
return basicTag("figure", content);
}
export function hrTag(): Tag {
return selfClosingTag("hr");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { HtmlContent, HtmlContents, PageDescriptor, TagDescriptor } from "https://esm.town/v/xkonti/htmlInterfaces";
import { wrap } from "https://esm.town/v/xkonti/htmlUtils";
export class Page implements PageDescriptor {
title: string;
meta: string[] = [];
links: string[] = [];
scripts: string[] = [];
body: Array<HtmlContent> = [];
constructor(title: string) {
this.title = title;
}
apply(modifier: Array<(Page) => Page> | ((Page) => Page)): Page {
if (Array.isArray(modifier)) {
return modifier.reduce((acc, mod) => mod(acc), this);
} else {
return modifier(this);
}
}
addDescription(description: string) {
return this.addMeta("description", description);
}
addKeywords(keywords: string[]) {
return this.addMeta("description", keywords.join(", "));
}
addAuthor(name: string) {
return this.addMeta("author", name);
}
addMetaViewport(content: { [name: string]: string }): Page {
const contentSegments: string[] = [];
for (const [name, value] of Object.entries(content)) {
contentSegments.push(` ${name}="${value}"`);
}
return this.addMeta("viewport", contentSegments.join(", "));
}
addOpenGraphTag(property: string, content: string) {
this.meta.push(`<meta property="${property}" content="${content}" />`);
return this;
}
addMeta(name: string, content: string) {
this.meta.push(`<meta name="${name}" content="${content}" />`);
return this;
}
addCanonicalLink(href: string): Page {
this.links.push(`<link rel="canonical" href="${href}">`);
return this;
}
addFavicon(href: string): Page {
this.links.push(`<link rel="icon" href="${href}" type="image/x-icon">`);
return this;
}
addStylesheet(href: string): Page {
this.links.push(`<link rel="stylesheet" href="${href}">`);
return this;
}
addLink(htmlLink: string): Page {
this.links.push(htmlLink);
return this;
}
addScript(link: string, async: boolean = false, defer: boolean = false): Page {
let scriptTag = `<script src="${link}"`;
if (async) {
scriptTag += " async";
}
if (defer) {
scriptTag += " defer";
}
scriptTag += "></script>";
this.scripts.push(scriptTag);
return this;
}
addBody(content: HtmlContents): Page {
if (Array.isArray(content)) {
this.body = this.body.concat(content);
} else {
this.body.push(content);
}
return this;
}
toHtml(): string {
// HEAD
const headContents = [
`<title>${this.title}</title>`,
`<meta charset="UTF-8">`,
];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export type HtmlContent = TagDescriptor | string;
export type HtmlContents = Array<HtmlContent> | HtmlContent;
export interface PageDescriptor {
title: string;
meta: string[];
links: string[];
scripts: string[];
body: Array<HtmlContent>;
}
export interface TagDescriptor {
tag: string;
attributes: { [name: string]: string };
classes: string[];
content: Array<HtmlContent>;
mustSelfClose: boolean;
}
1
2
3
4
5
6
7
8
9
/**
* Wraps `content` between `open` and `close` content. If the content is a list, it puts each in a new line.
*/
export function wrap(open: string, content: string | string[], close: string): string {
let contentString: string = Array.isArray(content)
? content.join("\n")
: content;
return `${open}\n${contentString}\n${close}`;
}