diff --git a/packages/content/README.md b/packages/content/README.md deleted file mode 100644 index 9b1ec98..0000000 --- a/packages/content/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# @welshman/content [![version](https://badgen.net/npm/v/@welshman/content)](https://npmjs.com/package/@welshman/content) - -Utilities for parsing and rendering note content. Customizable via RenderOptions. - -```typescript -import {parse, render} from '@welshman/content' - -const content = "Hello
from https://coracle.tools! " -const parsed = parse({content, tags: []}) -// [ -// { type: 'text', value: 'Hello
from ', raw: 'Hello
from ' }, -// { -// type: 'link', -// value: { url: URL, isMedia: false }, -// raw: 'https://coracle.tools' -// }, -// { -// type: 'text', -// value: "! ", -// raw: "! " -// } -// ] - -const result = renderAsText(parsed) -// => Hello<br>from https://coracle.tools/! <script>alert('evil')</script> - -const result = renderAsHtml(parsed) -// => Hello<br>from coracle.tools/! <script>alert('evil')</script> -``` diff --git a/packages/feeds/package.json b/packages/feeds/package.json index 2e20763..dc70f81 100644 --- a/packages/feeds/package.json +++ b/packages/feeds/package.json @@ -21,7 +21,8 @@ "dependencies": { "@welshman/lib": "workspace:*", "@welshman/net": "workspace:*", - "@welshman/util": "workspace:*" + "@welshman/util": "workspace:*", + "trava": "^1.2.1" }, "devDependencies": { "rimraf": "~6.0.0", diff --git a/packages/feeds/src/display.ts b/packages/feeds/src/display.ts new file mode 100644 index 0000000..4a54798 --- /dev/null +++ b/packages/feeds/src/display.ts @@ -0,0 +1,206 @@ +import {now, uniq, displayList} from "@welshman/lib" +import { + FeedType, + Feed, + AddressFeed, + AuthorFeed, + CreatedAtFeed, + DVMFeed, + DifferenceFeed, + IDFeed, + IntersectionFeed, + GlobalFeed, + KindFeed, + ListFeed, + LabelFeed, + WOTFeed, + RelayFeed, + ScopeFeed, + SearchFeed, + TagFeed, + UnionFeed, +} from "./core.js" +import {getFeedArgs} from "./utils.js" + +export const displayAddressFeed = (feed: AddressFeed) => { + const n = getFeedArgs(feed).length + + return `${n} replaceable {n === 1 ? 'event' : 'events'}` +} + +export const displayAuthorFeed = (feed: AuthorFeed) => { + const n = getFeedArgs(feed).length + + return `events from ${n} replaceable {n === 1 ? 'person' : 'people'}` +} + +export const displayCreatedAtFeed = (feed: CreatedAtFeed) => { + const items = getFeedArgs(feed) + const descriptions: string[] = [] + + for (const {since, until, relative = []} of items) { + const parts: string[] = [] + + if (since) { + const timestamp = relative.includes("since") ? now() - since : since + parts.push(`after ${new Date(timestamp * 1000).toLocaleString()}`) + } + + if (until) { + const timestamp = relative.includes("until") ? now() - until : until + parts.push(`before ${new Date(timestamp * 1000).toLocaleString()}`) + } + + if (parts.length > 0) { + descriptions.push(parts.join(" and ")) + } + } + + if (descriptions.length === 0) { + return "events from any time" + } + + return `events ${displayList(descriptions, "or")}` +} + +export const displayDVMFeed = (feed: DVMFeed) => { + const items = getFeedArgs(feed) + const descriptions: string[] = [] + + for (const {kind, tags = [], relays = []} of items) { + const parts: string[] = [] + + parts.push(`kind ${kind}`) + + if (tags.length > 0) { + parts.push(`with ${tags.length} tag${tags.length === 1 ? "" : "s"}`) + } + + if (relays.length > 0) { + parts.push(`from ${displayList(relays)}`) + } + + descriptions.push(parts.join(" ")) + } + + return `events from DVM requests of ${displayList(descriptions)}` +} + +export const displayDifferenceFeed = (feed: DifferenceFeed) => { + const [base, ...excluded] = getFeedArgs(feed) + + return `all ${displayFeed(base)}, excluding ${displayList(excluded.map(displayFeed))}` +} + +export const displayIDFeed = (feed: IDFeed) => `${getFeedArgs(feed).length} events` + +export const displayIntersectionFeed = (feed: IntersectionFeed) => + `events matching ${displayList(getFeedArgs(feed).map(displayFeed))}` + +export const displayGlobalFeed = (feed: GlobalFeed) => "anything" + +export const displayKindFeed = (feed: KindFeed) => + `events of kind ${displayList(getFeedArgs(feed))}` + +export const displayListFeed = (feed: ListFeed) => { + const addresses = uniq(getFeedArgs(feed).flatMap(({addresses}) => addresses)) + + return `events from ${addresses.length} list${addresses.length === 1 ? "" : "s"}` +} + +export const displayLabelFeed = (feed: LabelFeed) => { + const items = getFeedArgs(feed) + const descriptions: string[] = [] + + for (const item of items) { + const parts: string[] = [] + + if (item.authors?.length) { + parts.push(`by ${item.authors.length} author${item.authors.length === 1 ? "" : "s"}`) + } + + const tags = Object.entries(item) + .filter(([key]) => key.startsWith("#")) + .map(([key, values]) => `${key}=${displayList(values as string[])}`) + + if (tags.length) { + parts.push(`with tags ${displayList(tags)}`) + } + + if (item.relays?.length) { + parts.push(`from ${displayList(item.relays)}`) + } + + descriptions.push(parts.join(" ")) + } + + return `events ${displayList(descriptions)}` +} + +export const displayWOTFeed = (feed: WOTFeed) => { + const descriptions = getFeedArgs(feed).map(({min = 0, max = 1}) => + min === max ? `WOT score of ${min}` : `WOT score between ${min} and ${max}`, + ) + + return `Events from authors with ${displayList(descriptions)}` +} + +export const displayRelayFeed = (feed: RelayFeed) => `events from ${displayList(getFeedArgs(feed))}` + +export const displayScopeFeed = (feed: ScopeFeed) => + `events from ${displayList(getFeedArgs(feed).map(s => s.toLowerCase()))}` + +export const displaySearchFeed = (feed: SearchFeed) => + `events matching ${displayList(getFeedArgs(feed).map(term => `"${term}"`))}` + +export const displayTagFeed = (feed: TagFeed) => { + const [key, ...values] = getFeedArgs(feed) + + return `events with ${key} tag matching ${displayList(values, "or")}` +} + +export const displayUnionFeed = (feed: UnionFeed) => + `all ${displayList(getFeedArgs(feed).map(displayFeed))}` + +export const displayFeed = (feed: Feed): string => { + switch (feed[0]) { + case FeedType.Address: + return displayAddressFeed(feed) + case FeedType.Author: + return displayAuthorFeed(feed) + case FeedType.CreatedAt: + return displayCreatedAtFeed(feed) + case FeedType.DVM: + return displayDVMFeed(feed) + case FeedType.Difference: + return displayDifferenceFeed(feed) + case FeedType.ID: + return displayIDFeed(feed) + case FeedType.Intersection: + return displayIntersectionFeed(feed) + case FeedType.Global: + return displayGlobalFeed(feed) + case FeedType.Kind: + return displayKindFeed(feed) + case FeedType.List: + return displayListFeed(feed) + case FeedType.Label: + return displayLabelFeed(feed) + case FeedType.WOT: + return displayWOTFeed(feed) + case FeedType.Relay: + return displayRelayFeed(feed) + case FeedType.Scope: + return displayScopeFeed(feed) + case FeedType.Search: + return displaySearchFeed(feed) + case FeedType.Tag: + return displayTagFeed(feed) + case FeedType.Union: + return displayUnionFeed(feed) + default: + return "[unknown feed type]" + } +} + +export const displayFeeds = (feeds: Feed[]) => displayList(feeds.map(displayFeed)) diff --git a/packages/feeds/src/index.ts b/packages/feeds/src/index.ts index a03773e..b7b4269 100644 --- a/packages/feeds/src/index.ts +++ b/packages/feeds/src/index.ts @@ -1,4 +1,6 @@ export * from "./core.js" export * from "./compiler.js" export * from "./controller.js" +export * from "./display.js" export * from "./utils.js" +export * from "./validate.js" diff --git a/packages/feeds/src/validate.ts b/packages/feeds/src/validate.ts new file mode 100644 index 0000000..217360b --- /dev/null +++ b/packages/feeds/src/validate.ts @@ -0,0 +1,177 @@ +import Trava from "trava" +import {spec, isPojo} from "@welshman/lib" +import {isRelayUrl, Address} from "@welshman/util" +import {Scope, FeedType} from "./core.js" +import {getFeedArgs} from "./utils.js" + +const {ValidationError, Compose, Check, Each, Optional, Keys} = Trava + +const validateNumber = Check((x: any) => typeof x === "number", "Value must be a number") + +const validateString = Check((x: any) => typeof x === "string", "Value must be a string") + +const validateRelay = Check((x: any) => typeof x === "string" && isRelayUrl(x), "Invalid relay url") + +const validateScope = Check((x: any) => Object.values(Scope).includes(x), "Invalid scope") + +const validateHex = Check((x: any) => typeof x === "string" && x.length === 64, "Invalid hex value") + +const validateAddress = Check( + (x: any) => typeof x === "string" && Address.isAddress(x), + "Invalid address", +) + +const validateArray = Check((x: any) => Array.isArray(x), "Value must be an array") + +const validateObject = Check((x: any) => isPojo(x), "Value must be an object") + +export {ValidationError} + +export const validateTagFeedMapping = Compose([ + validateArray, + Check(spec({length: 2}), "Tag feed mappings must have two entries"), + (a: any[]) => validateString(a[0]), + (a: any[]) => validateFeed(a[1]), +]) + +export const validateFeedArgs = (validateArgument: Trava.Validator) => (feed: any) => { + let error = validateArray(feed) + if (error instanceof ValidationError) return error + + for (const argument of getFeedArgs(feed)) { + error = validateArgument(argument) + if (error instanceof ValidationError) return error + } + + return feed +} + +export const validateAddressFeed = validateFeedArgs(validateAddress) + +export const validateAuthorFeed = validateFeedArgs(validateHex) + +export const validateCreatedAtFeed = validateFeedArgs( + Keys({ + since: Optional(validateNumber), + until: Optional(validateNumber), + relative: Optional(Each(validateString)), + }), +) + +export const validateDVMFeed = validateFeedArgs( + Keys({ + kind: validateNumber, + tags: Optional(Each(validateString)), + relays: Optional(Each(validateString)), + mappings: Optional(Each(validateTagFeedMapping)), + }), +) + +export const validateDifferenceFeed = validateFeedArgs((x: any) => validateFeed(x)) + +export const validateIDFeed = validateFeedArgs(validateHex) + +export const validateIntersectionFeed = validateFeedArgs((x: any) => validateFeed(x)) + +export const validateGlobalFeed = validateFeedArgs((x: any) => validateFeed(x)) + +export const validateKindFeed = validateFeedArgs(validateNumber) + +export const validateListFeed = validateFeedArgs( + Keys({ + addresses: Optional(Each(validateString)), + mappings: Optional(Each(validateTagFeedMapping)), + }), +) + +export const validateLabelFeed = validateFeedArgs( + Compose([ + validateObject, + (item: any) => { + const validateRelays = Each(validateRelay) + const validateAuthors = Each(validateHex) + const validateStrings = Each(validateString) + const validateMappings = Each(validateTagFeedMapping) + + for (const [key, value] of Object.entries(item)) { + let error + if (key === "relays") { + error = validateRelays(value) + } else if (key === "authors") { + error = validateAuthors(value) + } else if (key === "mappings") { + error = validateMappings(value) + } else if (key.match("^#.$")) { + error = validateStrings(value) + } else { + error = new ValidationError("Invalid label item") + } + + if (error instanceof ValidationError) return error + } + + return item + }, + ]), +) + +export const validateWOTFeed = validateFeedArgs( + Keys({ + min: Optional(validateNumber), + max: Optional(validateNumber), + }), +) + +export const validateRelayFeed = validateFeedArgs(validateRelay) + +export const validateScopeFeed = validateFeedArgs(validateScope) + +export const validateSearchFeed = validateFeedArgs(validateString) + +export const validateTagFeed = validateFeedArgs(validateString) + +export const validateUnionFeed = validateFeedArgs((x: any) => validateFeed(x)) + +export const validateFeed = (feed: any) => { + const error = validateArray(feed) + if (error instanceof ValidationError) return error + + switch (feed[0]) { + case FeedType.Address: + return validateAddressFeed(feed) + case FeedType.Author: + return validateAuthorFeed(feed) + case FeedType.CreatedAt: + return validateCreatedAtFeed(feed) + case FeedType.DVM: + return validateDVMFeed(feed) + case FeedType.Difference: + return validateDifferenceFeed(feed) + case FeedType.ID: + return validateIDFeed(feed) + case FeedType.Intersection: + return validateIntersectionFeed(feed) + case FeedType.Global: + return validateGlobalFeed(feed) + case FeedType.Kind: + return validateKindFeed(feed) + case FeedType.List: + return validateListFeed(feed) + case FeedType.Label: + return validateLabelFeed(feed) + case FeedType.WOT: + return validateWOTFeed(feed) + case FeedType.Relay: + return validateRelayFeed(feed) + case FeedType.Scope: + return validateScopeFeed(feed) + case FeedType.Search: + return validateSearchFeed(feed) + case FeedType.Tag: + return validateTagFeed(feed) + case FeedType.Union: + return validateUnionFeed(feed) + default: + return new ValidationError("Unknown feed type") + } +} diff --git a/packages/lib/__tests__/Tools.test.ts b/packages/lib/__tests__/Tools.test.ts index 9ecb51f..33363de 100644 --- a/packages/lib/__tests__/Tools.test.ts +++ b/packages/lib/__tests__/Tools.test.ts @@ -310,6 +310,61 @@ describe("Tools", () => { }) }) + describe("displayList", () => { + it("should return an empty string when the list is empty", () => { + const list = [] + const output = T.displayList(list) + + expect(output).toEqual("") + }) + + it("should return a single entry when list length is one", () => { + const list = ["Apple"] + const output = T.displayList(list) + + expect(output).toEqual("Apple") + }) + + it("should return a string of both items when the list length is two", () => { + const list = ["Apple", "Banana"] + const output = T.displayList(list) + + expect(output).toEqual("Apple and Banana") + }) + + it("should return a string of all items when the list length is three", () => { + const list = ["Apple", "Banana", "Cherry"] + const output = T.displayList(list) + + expect(output).toEqual("Apple, Banana, and Cherry") + }) + + it("should return a truncated string of all items when the list is long", () => { + const list = [ + "Apple", + "Banana", + "Orange", + "Pear", + "Grapes", + "Mango", + "Pineapple", + "Kiwi", + "Strawberry", + "Blueberry", + ] + const output = T.displayList(list) + + expect(output).toEqual("Apple, Banana, Orange, Pear, Grapes, Mango, and 4 others") + }) + + it("should return a string of both items with list of two numbers", () => { + const list = [7, 17] + const output = T.displayList(list) + + expect(output).toEqual("7 and 17") + }) + }) + describe("Collection Operations", () => { it("should handle group operations", () => { const items = [ diff --git a/packages/lib/src/Tools.ts b/packages/lib/src/Tools.ts index e86c4de..f258ff2 100644 --- a/packages/lib/src/Tools.ts +++ b/packages/lib/src/Tools.ts @@ -10,7 +10,7 @@ export type Nil = null | undefined export const isNil = (x: T, ...args: unknown[]) => x === undefined || x === null -export const isNotNil = (x: T, ...args: unknown[]) => x !== undefined || x !== null +export const isNotNil = (x: T, ...args: unknown[]) => x !== undefined && x !== null export const assertNotNil = (x: T, ...args: unknown[]) => x! @@ -1358,6 +1358,19 @@ export const ellipsize = (s: string, l: number, suffix = "...") => { return s + suffix } +/** Displays a list of items with oxford commas and a chosen conjunction */ +export const displayList = (xs: T[], conj = "and", n = 6) => { + if (xs.length > n + 2) { + return `${xs.slice(0, n).join(", ")}, ${conj} ${xs.length - n} others` + } + + if (xs.length < 3) { + return xs.join(` ${conj} `) + } + + return `${xs.slice(0, -1).join(", ")}, ${conj} ${xs.slice(-1).join("")}` +} + /** Generates a hash string from input string */ export const hash = (s: string) => Math.abs(s.split("").reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0)).toString() diff --git a/packages/router/README.md b/packages/router/README.md deleted file mode 100644 index 9b1ec98..0000000 --- a/packages/router/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# @welshman/content [![version](https://badgen.net/npm/v/@welshman/content)](https://npmjs.com/package/@welshman/content) - -Utilities for parsing and rendering note content. Customizable via RenderOptions. - -```typescript -import {parse, render} from '@welshman/content' - -const content = "Hello
from https://coracle.tools! " -const parsed = parse({content, tags: []}) -// [ -// { type: 'text', value: 'Hello
from ', raw: 'Hello
from ' }, -// { -// type: 'link', -// value: { url: URL, isMedia: false }, -// raw: 'https://coracle.tools' -// }, -// { -// type: 'text', -// value: "! ", -// raw: "! " -// } -// ] - -const result = renderAsText(parsed) -// => Hello<br>from https://coracle.tools/! <script>alert('evil')</script> - -const result = renderAsHtml(parsed) -// => Hello<br>from coracle.tools/! <script>alert('evil')</script> -``` diff --git a/packages/store/README.md b/packages/store/README.md deleted file mode 100644 index f481232..0000000 --- a/packages/store/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# @welshman/store [![version](https://badgen.net/npm/v/@welshman/store)](https://npmjs.com/package/@welshman/store) - -Utilities for dealing with svelte stores when using welshman. - -```typescript -import {Repository, NAMED_PEOPLE, NAMED_TOPICS, type TrustedEvent, readUserList, List} from '@welshman/util' -import {deriveEventsMapped} from '@welshman/store' - -const repository = new Repository() - -// Create a svelte store that performantly maps matching events in the repository to List objects -const lists = deriveEventsMapped(repository, { - filters: [{kinds: [NAMED_PEOPLE, NAMED_TOPICS]}], - eventToItem: (event: TrustedEvent) => (event.tags.length > 1 ? readUserList(event) : null), - itemToEvent: (list: List) => list.event, -}) -``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bcb8a1..827676c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: '@welshman/util': specifier: workspace:* version: link:../util + trava: + specifier: ^1.2.1 + version: 1.2.1 devDependencies: rimraf: specifier: ~6.0.0 @@ -306,6 +309,15 @@ importers: specifier: ~5.8.0 version: 5.8.2 + packages/schema: + devDependencies: + rimraf: + specifier: ~6.0.0 + version: 6.0.1 + typescript: + specifier: ~5.8.0 + version: 5.8.2 + packages/signer: dependencies: '@noble/curves': @@ -2258,6 +2270,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + trava@1.2.1: + resolution: {integrity: sha512-2dt4qJEVtYIwCoihoO+HfVLN+uid7ibtKnZ4pjLF4YqyDOwGuP0uTGLTUnvNkAp92qh3YmgRv4wojxnj5yBPAQ==} + engines: {node: '>=6.0.0', npm: '>=4.0.0'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4400,6 +4416,8 @@ snapshots: dependencies: is-number: 7.0.0 + trava@1.2.1: {} + trim-lines@3.0.1: {} ts-api-utils@2.1.0(typescript@5.8.2):