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 [](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 [](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 [](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):