@pathmx/core 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/LICENSE.md +172 -0
  2. package/README.md +111 -0
  3. package/actions/actions.ts +307 -0
  4. package/actions/draft.ts +105 -0
  5. package/actions/transaction.ts +164 -0
  6. package/assets.ts +149 -0
  7. package/authority/access.ts +820 -0
  8. package/authority/actors.ts +271 -0
  9. package/authority/agents.ts +95 -0
  10. package/authority/credentials.ts +144 -0
  11. package/authority/model.ts +71 -0
  12. package/authority/sharing.ts +89 -0
  13. package/browser-bundle.ts +61 -0
  14. package/canonical.ts +41 -0
  15. package/cli/dev.ts +134 -0
  16. package/cli/export.ts +74 -0
  17. package/cli/index.ts +52 -0
  18. package/cli/lint.ts +164 -0
  19. package/cli/mv.ts +30 -0
  20. package/cli/perms.ts +182 -0
  21. package/cli/plugin-packages.ts +159 -0
  22. package/cli/plugins.ts +95 -0
  23. package/cli/query.ts +225 -0
  24. package/cli/readme.md +95 -0
  25. package/cli/release.ts +75 -0
  26. package/cli/rm.ts +22 -0
  27. package/cli/serve.ts +100 -0
  28. package/cli/tree.ts +131 -0
  29. package/cli/update.ts +78 -0
  30. package/cli/utils.ts +53 -0
  31. package/core.ts +96 -0
  32. package/database.ts +189 -0
  33. package/dependencies.ts +60 -0
  34. package/dist/pathmx.js +1224 -0
  35. package/document.ts +194 -0
  36. package/environment.ts +110 -0
  37. package/environments/bun-plugins.ts +68 -0
  38. package/environments/bun-publish.ts +194 -0
  39. package/environments/bun.ts +223 -0
  40. package/globals.d.ts +14 -0
  41. package/graph.ts +153 -0
  42. package/host/compiler.ts +314 -0
  43. package/host/create.ts +66 -0
  44. package/host/engine.ts +630 -0
  45. package/host/plugin-host.ts +487 -0
  46. package/host/render.ts +102 -0
  47. package/host/response.ts +20 -0
  48. package/host/shell.html +15 -0
  49. package/host/source-sync.ts +254 -0
  50. package/html-attributes.ts +15 -0
  51. package/html.ts +48 -0
  52. package/icons/LICENSE +43 -0
  53. package/icons/lucide.ts +54 -0
  54. package/index.ts +75 -0
  55. package/invalidation.ts +85 -0
  56. package/log.ts +84 -0
  57. package/ops/href.ts +87 -0
  58. package/ops/index.ts +9 -0
  59. package/ops/move-source.ts +114 -0
  60. package/ops/plan.ts +47 -0
  61. package/ops/remove-source.ts +70 -0
  62. package/package.json +46 -0
  63. package/path.ts +75 -0
  64. package/pattern.ts +314 -0
  65. package/perf.ts +88 -0
  66. package/plugins/actor-path.ts +43 -0
  67. package/plugins/actor-profile.ts +55 -0
  68. package/plugins/agent-browsing.ts +183 -0
  69. package/plugins/app-component.ts +23 -0
  70. package/plugins/block-layout/index.ts +78 -0
  71. package/plugins/block-layout/theme.css +90 -0
  72. package/plugins/callout/index.ts +236 -0
  73. package/plugins/callout/theme.css +138 -0
  74. package/plugins/canonical-links.ts +144 -0
  75. package/plugins/code-highlight/client.ts +89 -0
  76. package/plugins/code-highlight/index.ts +147 -0
  77. package/plugins/code-highlight/theme.css +89 -0
  78. package/plugins/context.ts +131 -0
  79. package/plugins/datatype/Datatype[wdth,wght].woff2 +0 -0
  80. package/plugins/datatype/OFL.txt +96 -0
  81. package/plugins/datatype/index.ts +287 -0
  82. package/plugins/definition-list.ts +236 -0
  83. package/plugins/directive-definitions.ts +13 -0
  84. package/plugins/embedding/client.ts +88 -0
  85. package/plugins/embedding/index.ts +43 -0
  86. package/plugins/image/index.ts +68 -0
  87. package/plugins/image/theme.css +43 -0
  88. package/plugins/include.ts +119 -0
  89. package/plugins/index.ts +91 -0
  90. package/plugins/input/authoring.ts +236 -0
  91. package/plugins/input/config.ts +85 -0
  92. package/plugins/input/index.ts +359 -0
  93. package/plugins/input/model.ts +173 -0
  94. package/plugins/input/state.ts +272 -0
  95. package/plugins/layout.ts +313 -0
  96. package/plugins/literate/client.ts +208 -0
  97. package/plugins/literate/index.ts +742 -0
  98. package/plugins/math/index.ts +140 -0
  99. package/plugins/meta.ts +42 -0
  100. package/plugins/navigation/index.ts +158 -0
  101. package/plugins/navigation/model.ts +364 -0
  102. package/plugins/preset.ts +65 -0
  103. package/plugins/query/index.ts +322 -0
  104. package/plugins/rss/index.ts +46 -0
  105. package/plugins/runtime/actions.ts +62 -0
  106. package/plugins/runtime/client.ts +8 -0
  107. package/plugins/runtime/document.ts +506 -0
  108. package/plugins/runtime/error.ts +154 -0
  109. package/plugins/runtime/heading-links.ts +31 -0
  110. package/plugins/runtime/index.ts +104 -0
  111. package/plugins/runtime/live.ts +51 -0
  112. package/plugins/runtime/navigation.ts +113 -0
  113. package/plugins/runtime/pending.ts +53 -0
  114. package/plugins/runtime/publication.ts +19 -0
  115. package/plugins/runtime/start.ts +177 -0
  116. package/plugins/runtime/static-client.ts +6 -0
  117. package/plugins/runtime/static-live.ts +188 -0
  118. package/plugins/runtime/view-transition.ts +72 -0
  119. package/plugins/runtime/viewport.ts +110 -0
  120. package/plugins/schema.ts +24 -0
  121. package/plugins/scripts.ts +140 -0
  122. package/plugins/source-preview/client.ts +256 -0
  123. package/plugins/source-preview/index.ts +132 -0
  124. package/plugins/source-preview/model.ts +11 -0
  125. package/plugins/source-preview/preview.css +107 -0
  126. package/plugins/styles.ts +145 -0
  127. package/plugins/theme/components.ts +10 -0
  128. package/plugins/theme/foundation.css +358 -0
  129. package/plugins/theme/index.ts +333 -0
  130. package/plugins/theme/prose.css +284 -0
  131. package/plugins/theme/scheme.js +63 -0
  132. package/plugins/theme/toggle.css +44 -0
  133. package/plugins/theme/view-transition.css +68 -0
  134. package/plugins/toc/client.ts +146 -0
  135. package/plugins/toc/index.ts +232 -0
  136. package/plugins/toc/theme.css +172 -0
  137. package/plugins/types.ts +538 -0
  138. package/problem.ts +41 -0
  139. package/project.ts +6 -0
  140. package/publish/build.ts +402 -0
  141. package/publish/concurrent.ts +32 -0
  142. package/publish/identity.ts +63 -0
  143. package/publish/index.ts +11 -0
  144. package/publish/location.ts +167 -0
  145. package/publish/model.ts +58 -0
  146. package/publish/plan.ts +300 -0
  147. package/publish/validate.ts +150 -0
  148. package/query/candidate-index.ts +101 -0
  149. package/query/dependency-index.ts +99 -0
  150. package/query/fields.ts +216 -0
  151. package/query/invalidation.ts +48 -0
  152. package/query/kernel.ts +776 -0
  153. package/query/model.ts +79 -0
  154. package/repository.ts +374 -0
  155. package/runtime-profile.ts +34 -0
  156. package/schema/model.ts +92 -0
  157. package/schema/registry.ts +642 -0
  158. package/schema/standard.ts +226 -0
  159. package/server/context.ts +94 -0
  160. package/server/css-imports.ts +66 -0
  161. package/server/http.ts +333 -0
  162. package/server/page.ts +21 -0
  163. package/server/protocols.ts +12 -0
  164. package/server/representations.ts +66 -0
  165. package/server/router.ts +131 -0
  166. package/server/routes.ts +202 -0
  167. package/server/serve.ts +5 -0
  168. package/server/watch.ts +120 -0
  169. package/source/blocks.ts +71 -0
  170. package/source/format.ts +164 -0
  171. package/source/index.ts +67 -0
  172. package/source/interpolate.ts +63 -0
  173. package/source/markdown/blocks.ts +46 -0
  174. package/source/markdown/comments.ts +32 -0
  175. package/source/markdown/fences.ts +67 -0
  176. package/source/markdown/headings.ts +88 -0
  177. package/source/markdown/index.ts +34 -0
  178. package/source/markdown/inline-code.ts +6 -0
  179. package/source/markdown/links.ts +326 -0
  180. package/source/markdown/lists.ts +223 -0
  181. package/source/markdown/prose.ts +74 -0
  182. package/source/markdown/scan.ts +79 -0
  183. package/source/markdown/text.ts +82 -0
  184. package/source/meta.ts +87 -0
  185. package/source/source.ts +121 -0
  186. package/source/topmatter.ts +57 -0
  187. package/source/utils.ts +21 -0
  188. package/source-diagnostic.ts +63 -0
  189. package/text-file.ts +26 -0
  190. package/util.ts +35 -0
  191. package/view/components.ts +499 -0
  192. package/view/model.ts +91 -0
  193. package/view/projection.ts +162 -0
  194. package/view/session.ts +146 -0
  195. package/view/syntax.ts +372 -0
@@ -0,0 +1,57 @@
1
+ import type { PathMXYaml } from "../environment.ts"
2
+
3
+ export type Topmatter = Record<string, unknown>
4
+
5
+ /** @internal Identifies failures raised while decoding an authored YAML map. */
6
+ export class YamlMapParseError extends Error {
7
+ constructor(cause: unknown) {
8
+ super(cause instanceof Error ? cause.message : String(cause), { cause })
9
+ this.name = "YamlMapParseError"
10
+ }
11
+ }
12
+
13
+ export function parseYamlMap(text: string, yaml: PathMXYaml): Topmatter {
14
+ let parsed: unknown
15
+ try {
16
+ parsed = yaml.parse(text)
17
+ } catch (error) {
18
+ throw new YamlMapParseError(error)
19
+ }
20
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
21
+ ? (parsed as Topmatter)
22
+ : {}
23
+ }
24
+
25
+ function emptySplit(body: string) {
26
+ return { data: {} as Topmatter, body }
27
+ }
28
+
29
+ /** YAML frontmatter: a leading `---` … `---` map. */
30
+ export function splitFrontmatter(content: string, yaml?: PathMXYaml) {
31
+ const match = content.match(
32
+ /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/,
33
+ )
34
+ if (!match) return emptySplit(content)
35
+ if (!yaml) throw new Error("A YAML codec is required to parse frontmatter.")
36
+ return {
37
+ data: parseYamlMap(match[1]!, yaml),
38
+ body: content.slice(match[0].length),
39
+ }
40
+ }
41
+
42
+ function startsWithTopmatterKey(comment: string) {
43
+ const firstContentLine = comment
44
+ .split(/\r?\n/)
45
+ .find((line) => line.trim() && !line.trimStart().startsWith("#"))
46
+ return /^[ \t]*[A-Za-z0-9_.-]+:[ \t]*/.test(firstContentLine ?? "")
47
+ }
48
+
49
+ /** HTML-comment topmatter: `<!-- id: x -->` or a multiline `<!-- ... -->` YAML map. */
50
+ export function splitTopmatter(content: string, yaml?: PathMXYaml) {
51
+ const match = content.match(/^\s*<!--([\s\S]*?)-->/)
52
+ if (!match?.[1] || !startsWithTopmatterKey(match[1])) return emptySplit(content)
53
+ if (!yaml) throw new Error("A YAML codec is required to parse topmatter.")
54
+ const data = parseYamlMap(match[1], yaml)
55
+ if (!Object.keys(data).length) return emptySplit(content)
56
+ return { data, body: content.slice(match[0].length) }
57
+ }
@@ -0,0 +1,21 @@
1
+ export type SourcePathNameMeta = Readonly<{
2
+ name: string
3
+ typeHint?: string
4
+ qualifiers: readonly string[]
5
+ }>
6
+
7
+ // Remove the format extension, then interpret the dot-delimited filename stem
8
+ // as `name[.qualifier...].type`.
9
+ export function parseSourceTypeAndQualifiers(path: string): SourcePathNameMeta {
10
+ const basename = path.split("/").filter(Boolean).at(-1) ?? path
11
+ const filenameStem = basename.replace(/\.[^.]+$/, "")
12
+ const segments = filenameStem.split(".").filter(Boolean)
13
+ const typeHint = segments.length > 1 ? segments.at(-1) : undefined
14
+ const qualifiers = segments.length > 2 ? segments.slice(1, -1) : []
15
+ const name = (segments[0] ?? filenameStem).trim()
16
+ return {
17
+ name,
18
+ typeHint,
19
+ qualifiers,
20
+ }
21
+ }
@@ -0,0 +1,63 @@
1
+ import type { Log } from "./log.ts"
2
+
3
+ export type SourceDiagnostic = Readonly<{
4
+ source: string
5
+ plugin: string
6
+ message: string
7
+ }>
8
+
9
+ export type SourceDiagnosticMode = "throw" | "collect"
10
+
11
+ /** Expected authoring failure that lint can collect without hiding code bugs. */
12
+ export class SourceDiagnosticError extends Error {
13
+ readonly diagnostic: SourceDiagnostic
14
+
15
+ constructor(diagnostic: SourceDiagnostic) {
16
+ super(diagnostic.message)
17
+ this.name = "SourceDiagnosticError"
18
+ this.diagnostic = diagnostic
19
+ }
20
+ }
21
+
22
+ /** Index-time diagnostic policy and stable per-Source results. */
23
+ export class SourceDiagnostics {
24
+ private sources = new Map<string, Map<string, SourceDiagnostic>>()
25
+
26
+ constructor(
27
+ private mode: SourceDiagnosticMode,
28
+ private log: Log,
29
+ ) {}
30
+
31
+ delete(sourcePath: string) {
32
+ this.sources.delete(sourcePath)
33
+ }
34
+
35
+ capture(error: unknown) {
36
+ if (this.mode !== "collect" || !(error instanceof SourceDiagnosticError)) {
37
+ return false
38
+ }
39
+ const diagnostic = error.diagnostic
40
+ let source = this.sources.get(diagnostic.source)
41
+ if (!source) {
42
+ source = new Map()
43
+ this.sources.set(diagnostic.source, source)
44
+ }
45
+ const key = `${diagnostic.plugin}\0${diagnostic.message}`
46
+ if (!source.has(key)) {
47
+ source.set(key, diagnostic)
48
+ this.log.error("source.invalid", diagnostic)
49
+ }
50
+ return true
51
+ }
52
+
53
+ all(): readonly SourceDiagnostic[] {
54
+ return [...this.sources.values()]
55
+ .flatMap((source) => [...source.values()])
56
+ .sort(
57
+ (a, b) =>
58
+ a.source.localeCompare(b.source) ||
59
+ a.plugin.localeCompare(b.plugin) ||
60
+ a.message.localeCompare(b.message),
61
+ )
62
+ }
63
+ }
package/text-file.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { PathMXStorage } from "./environment.ts"
2
+
3
+ export type TextFileState =
4
+ Readonly<{ type: "missing" }> | Readonly<{ type: "present"; content: string }>
5
+
6
+ const missing: TextFileState = Object.freeze({ type: "missing" })
7
+
8
+ /** Capture the text state used for optimistic storage conflict checks. */
9
+ export async function readTextFileState(
10
+ storage: PathMXStorage,
11
+ path: string,
12
+ ): Promise<TextFileState> {
13
+ if (!(await storage.exists(path))) return missing
14
+ return Object.freeze({
15
+ type: "present",
16
+ content: await storage.readText(path),
17
+ })
18
+ }
19
+
20
+ export function sameTextFileState(left: TextFileState, right: TextFileState) {
21
+ return (
22
+ left.type === right.type &&
23
+ (left.type === "missing" ||
24
+ (right.type === "present" && left.content === right.content))
25
+ )
26
+ }
package/util.ts ADDED
@@ -0,0 +1,35 @@
1
+ export function asList(value: unknown) {
2
+ if (Array.isArray(value)) return value.map(String).filter(Boolean);
3
+ if (value == null || value === "") return [];
4
+ return [String(value)];
5
+ }
6
+
7
+ export function escapeHtml(value: string) {
8
+ return value
9
+ .replace(/&/g, "&amp;")
10
+ .replace(/</g, "&lt;")
11
+ .replace(/>/g, "&gt;")
12
+ .replace(/"/g, "&quot;")
13
+ }
14
+
15
+ /** Reverse `escapeHtml`, plus named/numeric character references. */
16
+ export function decodeHtmlEntities(value: string) {
17
+ if (!value.includes("&")) return value
18
+ return value
19
+ .replace(/&lt;/g, "<")
20
+ .replace(/&gt;/g, ">")
21
+ .replace(/&quot;/g, '"')
22
+ .replace(/&#39;|&apos;/g, "'")
23
+ .replace(/&#x([0-9a-f]+);/gi, (_match, hex: string) =>
24
+ String.fromCodePoint(parseInt(hex, 16)),
25
+ )
26
+ .replace(/&#(\d+);/g, (_match, dec: string) =>
27
+ String.fromCodePoint(Number(dec)),
28
+ )
29
+ .replace(/&amp;/g, "&")
30
+ }
31
+
32
+ export function fileStem(path: string, suffix: string) {
33
+ const base = path.split("/").pop() ?? "";
34
+ return base.endsWith(suffix) ? base.slice(0, -suffix.length) : undefined;
35
+ }
@@ -0,0 +1,499 @@
1
+ import type { Engine } from "../host/engine.ts"
2
+ import { html, stringifyHtml, type Html } from "../html.ts"
3
+ import type {
4
+ AppContext,
5
+ AuthoredDataComponent,
6
+ AnyViewComponent,
7
+ CompileContext,
8
+ PageContext,
9
+ Plugin,
10
+ ViewComponentUse,
11
+ ViewContext,
12
+ ViewVariable,
13
+ } from "../plugins/types.ts"
14
+ import type { Source } from "../source/index.ts"
15
+ import {
16
+ formatSchemaIssues,
17
+ requireStandardJsonSchema,
18
+ type StandardSchemaV1,
19
+ } from "../schema/standard.ts"
20
+ import type { HtmlStringRewrite } from "../environment.ts"
21
+ import { parseHtmlAttributes } from "../html-attributes.ts"
22
+ import {
23
+ authoredViewTag,
24
+ compileViewUses,
25
+ COMPONENT_ORIGIN_ATTR,
26
+ hasRemainingViewUse,
27
+ isAuthoredViewTag,
28
+ isLiterateComponentTag,
29
+ isViewComponentTag,
30
+ stampComponentRoot,
31
+ stripComponentOrigins,
32
+ ViewComponentSyntax,
33
+ viewUsePattern,
34
+ VIEW_NAME_ATTR,
35
+ VIEW_SLOT_ATTR,
36
+ VIEW_USE_TAG,
37
+ } from "./syntax.ts"
38
+
39
+ type RegisteredView = {
40
+ plugin: string
41
+ tag: string
42
+ authoredTag?: string
43
+ props?: StandardSchemaV1
44
+ data?: StandardSchemaV1
45
+ available?: (origin: Source) => boolean
46
+ compile?: (
47
+ use: ViewComponentUse,
48
+ ctx: PageContext,
49
+ app: AppContext,
50
+ ) => unknown | Promise<unknown>
51
+ render: (
52
+ use: ViewComponentUse,
53
+ compiled: unknown,
54
+ ctx: ViewContext,
55
+ app: AppContext,
56
+ ) => Html | Promise<Html>
57
+ }
58
+
59
+ type CompiledView = {
60
+ marker: string
61
+ view: RegisteredView
62
+ use: ViewComponentUse
63
+ compiled: unknown
64
+ }
65
+
66
+ type ViewSegment = string | CompiledView
67
+
68
+ export type CompiledViewPlan = Readonly<{
69
+ html: string
70
+ segments: readonly ViewSegment[]
71
+ }>
72
+
73
+ function viewPlan(
74
+ html: string,
75
+ compiled: readonly CompiledView[],
76
+ ): CompiledViewPlan {
77
+ const byMarker = new Map(compiled.map((item) => [item.marker, item]))
78
+ const segments: ViewSegment[] = []
79
+ let cursor = 0
80
+ let found = 0
81
+ for (const match of html.matchAll(
82
+ /<template data-pmx-view-slot="[a-z0-9]+-[a-z0-9]+"><\/template>/g,
83
+ )) {
84
+ const item = byMarker.get(match[0])
85
+ if (!item) continue
86
+ if (match.index > cursor) segments.push(html.slice(cursor, match.index))
87
+ segments.push(item)
88
+ cursor = match.index + match[0].length
89
+ found++
90
+ }
91
+ if (found !== compiled.length) {
92
+ throw new Error("Compiled view slots do not match compiled HTML.")
93
+ }
94
+ if (cursor < html.length) segments.push(html.slice(cursor))
95
+ return { html, segments }
96
+ }
97
+
98
+ function registeredView(
99
+ plugin: string,
100
+ view: AnyViewComponent,
101
+ ): RegisteredView {
102
+ const client = view.client
103
+ const compile = client
104
+ ? async (use: ViewComponentUse, ctx: PageContext, app: AppContext) => {
105
+ const [compiled] = await Promise.all([
106
+ view.compile?.(use, ctx, app),
107
+ ctx.addBrowserBundle(client, view.tag),
108
+ ])
109
+ return compiled
110
+ }
111
+ : view.compile
112
+ return {
113
+ plugin,
114
+ tag: view.tag,
115
+ authoredTag: authoredViewTag(view.tag),
116
+ props: view.props,
117
+ data: view.data,
118
+ compile,
119
+ render: (use, compiled, ctx, env) => view.render(use, compiled, ctx, env),
120
+ }
121
+ }
122
+
123
+ function registeredVariable(plugin: string, variable: ViewVariable) {
124
+ const tag = `${variable.namespace}:$variable`
125
+ const view: RegisteredView = {
126
+ plugin,
127
+ tag,
128
+ render: async (use, _compiled, ctx, env) => {
129
+ const path = use.props["data-pmx-variable"]?.split(".") ?? []
130
+ const value = await variable.render(
131
+ { path, origin: use.origin },
132
+ ctx,
133
+ env,
134
+ )
135
+ return html`${value}`
136
+ },
137
+ }
138
+ return view
139
+ }
140
+
141
+ async function validateProps(view: RegisteredView, use: ViewComponentUse) {
142
+ if (!view.props) return
143
+ let result = view.props["~standard"].validate(use.props)
144
+ if (result instanceof Promise) result = await result
145
+ if (!result.issues) return
146
+ throw new Error(
147
+ `Invalid <${view.authoredTag ?? view.tag}> props in ${use.origin.path}: ${formatSchemaIssues(result.issues)}`,
148
+ )
149
+ }
150
+
151
+ async function validatedData(view: RegisteredView, data: unknown) {
152
+ if (!view.data) return data
153
+ let result = view.data["~standard"].validate(data)
154
+ if (result instanceof Promise) result = await result
155
+ if (!result.issues) return result.value
156
+ throw new Error(
157
+ `Invalid <${view.authoredTag ?? view.tag}> data: ${formatSchemaIssues(result.issues)}`,
158
+ )
159
+ }
160
+
161
+ export class ViewComponentRegistry {
162
+ private views = new Map<string, RegisteredView>()
163
+ private authoredViews = new Map<string, RegisteredView>()
164
+ private variableNamespaces = new Set<string>()
165
+ private dataViews = new Map<string, RegisteredView & { owner: string }>()
166
+ private dataViewsByOwner = new Map<string, Set<string>>()
167
+ private syntax = new ViewComponentSyntax()
168
+ private generation = 0
169
+
170
+ has(tag: string) {
171
+ return this.views.has(tag) || this.authoredViews.has(tag)
172
+ }
173
+
174
+ compileVariables(markdown: string, _ctx: CompileContext) {
175
+ return this.syntax.compileVariables(markdown)
176
+ }
177
+
178
+ compileSyntax(markdown: string, ctx: CompileContext) {
179
+ return this.syntax.compile(markdown, ctx)
180
+ }
181
+
182
+ restoreSyntax(html: string, ctx: CompileContext) {
183
+ return this.syntax.restore(html, ctx)
184
+ }
185
+
186
+ register(plugin: Plugin) {
187
+ const pending = new Map<string, RegisteredView>()
188
+ const pendingAuthored = new Map<string, RegisteredView>()
189
+ const pendingVariables = new Map<string, RegisteredView>()
190
+ for (const view of plugin.components ?? []) {
191
+ const tag = view.tag
192
+ if (!isViewComponentTag(tag)) {
193
+ throw new Error(`Invalid View Component tag: ${tag}`)
194
+ }
195
+ if (!tag.startsWith(`${plugin.id}:`)) {
196
+ throw new Error(
197
+ `View Component <${tag}> must use the ${plugin.id}: namespace.`,
198
+ )
199
+ }
200
+ if (pending.has(tag) || this.views.has(tag)) {
201
+ throw new Error(`Duplicate View Component tag: ${tag}`)
202
+ }
203
+ if (view.data) {
204
+ requireStandardJsonSchema(
205
+ view.data,
206
+ "input",
207
+ `View Component <${tag}> data`,
208
+ )
209
+ requireStandardJsonSchema(
210
+ view.data,
211
+ "output",
212
+ `View Component <${tag}> data`,
213
+ )
214
+ }
215
+ const registered = registeredView(plugin.id, view)
216
+ const authoredTag = registered.authoredTag!
217
+ if (!isAuthoredViewTag(authoredTag)) {
218
+ throw new Error(`Invalid authored View Component tag: ${authoredTag}`)
219
+ }
220
+ if (
221
+ pendingAuthored.has(authoredTag) ||
222
+ this.authoredViews.has(authoredTag)
223
+ ) {
224
+ throw new Error(`Duplicate authored View Component tag: ${authoredTag}`)
225
+ }
226
+ pending.set(tag, registered)
227
+ pendingAuthored.set(authoredTag, registered)
228
+ }
229
+ for (const variable of plugin.variables ?? []) {
230
+ const namespace = variable.namespace
231
+ if (namespace !== plugin.id) {
232
+ throw new Error(
233
+ `View Variable {{ ${namespace}.* }} must use the ${plugin.id} namespace.`,
234
+ )
235
+ }
236
+ if (
237
+ pendingVariables.has(namespace) ||
238
+ this.variableNamespaces.has(namespace)
239
+ ) {
240
+ throw new Error(`Duplicate View Variable namespace: ${namespace}`)
241
+ }
242
+ pendingVariables.set(namespace, registeredVariable(plugin.id, variable))
243
+ }
244
+ for (const [tag, view] of pending) this.views.set(tag, view)
245
+ for (const [tag, view] of pendingAuthored) this.authoredViews.set(tag, view)
246
+ for (const [namespace, view] of pendingVariables) {
247
+ this.variableNamespaces.add(namespace)
248
+ this.views.set(view.tag, view)
249
+ }
250
+ this.syntax.registerVariables(
251
+ [...pendingVariables].map(
252
+ ([namespace, view]) => [namespace, view.tag] as const,
253
+ ),
254
+ )
255
+ }
256
+
257
+ replaceAuthored(sourcePath: string, tags: Iterable<string>) {
258
+ this.syntax.replaceAuthored(sourcePath, tags)
259
+ }
260
+
261
+ deleteAuthored(sourcePath: string) {
262
+ this.syntax.deleteAuthored(sourcePath)
263
+ for (const tag of this.dataViewsByOwner.get(sourcePath) ?? []) {
264
+ this.dataViews.delete(tag)
265
+ }
266
+ this.dataViewsByOwner.delete(sourcePath)
267
+ }
268
+
269
+ replaceData(
270
+ plugin: string,
271
+ sourcePath: string,
272
+ definitions: Iterable<AuthoredDataComponent>,
273
+ ) {
274
+ const pending = [...definitions]
275
+ const tags = new Set<string>()
276
+ for (const definition of pending) {
277
+ if (!isLiterateComponentTag(definition.tag)) {
278
+ throw new Error(`Invalid authored data component: ${definition.tag}`)
279
+ }
280
+ if (tags.has(definition.tag)) {
281
+ throw new Error(`Duplicate authored data component: ${definition.tag}`)
282
+ }
283
+ const existing = this.dataViews.get(definition.tag)
284
+ if (existing && existing.owner !== sourcePath) {
285
+ throw new Error(
286
+ `Duplicate authored data component <${definition.tag}> in ${sourcePath} and ${existing.owner}.`,
287
+ )
288
+ }
289
+ tags.add(definition.tag)
290
+ }
291
+ for (const tag of this.dataViewsByOwner.get(sourcePath) ?? []) {
292
+ this.dataViews.delete(tag)
293
+ }
294
+ for (const definition of pending) {
295
+ this.dataViews.set(definition.tag, {
296
+ plugin,
297
+ owner: sourcePath,
298
+ tag: definition.tag,
299
+ available: definition.available,
300
+ compile: definition.compile
301
+ ? (_use, ctx, app) => definition.compile!(_use.origin, ctx, app)
302
+ : undefined,
303
+ render: (use, compiled, ctx, app) =>
304
+ definition.render(
305
+ use as ViewComponentUse<unknown>,
306
+ compiled,
307
+ ctx,
308
+ app,
309
+ ),
310
+ })
311
+ }
312
+ this.dataViewsByOwner.set(sourcePath, tags)
313
+ }
314
+
315
+ hasAny(tags: ReadonlySet<string> | undefined) {
316
+ if (!tags?.size) return false
317
+ for (const tag of tags) {
318
+ if (this.authoredViews.has(tag) || this.syntax.has(tag)) return true
319
+ }
320
+ return false
321
+ }
322
+
323
+ async prepareData(
324
+ tag: string,
325
+ props: Readonly<Record<string, string>>,
326
+ origin: Source,
327
+ ctx: PageContext,
328
+ env: Engine,
329
+ ) {
330
+ const view =
331
+ this.views.get(tag) ??
332
+ this.authoredViews.get(tag) ??
333
+ this.dataViews.get(tag)
334
+ if (!view || view.available?.(origin) === false) {
335
+ throw new Error(`Unknown data component <${tag}> in ${origin.path}.`)
336
+ }
337
+ if (!this.dataViews.has(tag) && !view.data) {
338
+ throw new Error(
339
+ `View Component <${view.authoredTag ?? tag}> does not declare data.`,
340
+ )
341
+ }
342
+ const use: ViewComponentUse = { props: Object.freeze({ ...props }), origin }
343
+ await validateProps(view, use)
344
+ const compiled = view.compile
345
+ ? await view.compile(use, ctx, env.appContext(view.plugin))
346
+ : undefined
347
+ return Object.freeze({ view, use, compiled })
348
+ }
349
+
350
+ async renderData(
351
+ prepared: unknown,
352
+ data: unknown,
353
+ ctx: ViewContext,
354
+ env: Engine,
355
+ ) {
356
+ const item = prepared as Readonly<{
357
+ view: RegisteredView
358
+ use: ViewComponentUse
359
+ compiled: unknown
360
+ }>
361
+ if (!item?.view || !item.use) throw new Error("Invalid prepared component.")
362
+ const value = await validatedData(item.view, data)
363
+ const app = env.appContext(item.view.plugin)
364
+ const rendered = await item.view.render(
365
+ { ...item.use, data: value } as ViewComponentUse<any>,
366
+ item.compiled,
367
+ Object.freeze({ ...ctx, database: app.database }),
368
+ app,
369
+ )
370
+ return stampComponentRoot(
371
+ stringifyHtml(rendered),
372
+ item.view.tag,
373
+ env.environment.rewriteHtml,
374
+ )
375
+ }
376
+
377
+ stampOrigins(
378
+ html: string,
379
+ originPath: string,
380
+ rewriteHtml: HtmlStringRewrite,
381
+ ) {
382
+ return this.syntax.stampOrigins(html, originPath, rewriteHtml)
383
+ }
384
+
385
+ async compile(html: string, source: Source, ctx: PageContext, env: Engine) {
386
+ html = compileViewUses(html, source.path)
387
+ if (!html.includes(`<${VIEW_USE_TAG}`)) {
388
+ return {
389
+ html: stripComponentOrigins(html),
390
+ plan: undefined,
391
+ }
392
+ }
393
+
394
+ const compiledViews: CompiledView[] = []
395
+ const generation = (this.generation++).toString(36)
396
+ const pattern = viewUsePattern()
397
+ let cursor = 0
398
+ let output = ""
399
+ let match: RegExpExecArray | null
400
+ while ((match = pattern.exec(html))) {
401
+ output += html.slice(cursor, match.index)
402
+ const attrs = parseHtmlAttributes(match[1])
403
+ const tag = attrs[VIEW_NAME_ATTR] ?? ""
404
+ delete attrs[VIEW_NAME_ATTR]
405
+ const view = this.views.get(tag) ?? this.authoredViews.get(tag)
406
+ if (!view) {
407
+ throw new Error(`Unknown View Component <${tag}> in ${source.path}.`)
408
+ }
409
+ const originPath = attrs[COMPONENT_ORIGIN_ATTR]
410
+ delete attrs[COMPONENT_ORIGIN_ATTR]
411
+ const origin =
412
+ (originPath && env.appContext(view.plugin).sources.get(originPath)) ||
413
+ source
414
+ const use: ViewComponentUse = {
415
+ props: Object.freeze(attrs),
416
+ origin,
417
+ }
418
+ await validateProps(view, use)
419
+ const start = performance.now()
420
+ let compiled: unknown
421
+ try {
422
+ compiled = view.compile
423
+ ? await view.compile(use, ctx, env.appContext(view.plugin))
424
+ : undefined
425
+ } finally {
426
+ env.perf.record(
427
+ `${view.plugin}.view.compile`,
428
+ performance.now() - start,
429
+ `${source.path} <${view.tag}>`,
430
+ )
431
+ }
432
+ const marker = `<template ${VIEW_SLOT_ATTR}="${generation}-${compiledViews.length.toString(36)}"></template>`
433
+ compiledViews.push({ marker, view, use, compiled })
434
+ output += marker
435
+ cursor = pattern.lastIndex
436
+ }
437
+ html = output + html.slice(cursor)
438
+ if (hasRemainingViewUse(html)) {
439
+ throw new Error(`Invalid compiled View Component use in ${source.path}.`)
440
+ }
441
+ html = stripComponentOrigins(html)
442
+ return {
443
+ html,
444
+ plan: compiledViews.length ? viewPlan(html, compiledViews) : undefined,
445
+ }
446
+ }
447
+
448
+ async renderSegments(
449
+ html: string,
450
+ plan: CompiledViewPlan | undefined,
451
+ ctx: ViewContext,
452
+ env: Engine,
453
+ ) {
454
+ if (!plan) return [html]
455
+ const pageAt = html.indexOf(plan.html)
456
+ if (pageAt === -1) {
457
+ throw new Error("Compiled view page does not match rendered HTML.")
458
+ }
459
+ const output: string[] = []
460
+ if (pageAt) output.push(html.slice(0, pageAt))
461
+ for (const segment of plan.segments) {
462
+ if (typeof segment === "string") {
463
+ output.push(segment)
464
+ continue
465
+ }
466
+ const start = performance.now()
467
+ let rendered: Html
468
+ try {
469
+ const appContext = env.appContext(segment.view.plugin)
470
+ const viewContext = Object.freeze({
471
+ ...ctx,
472
+ database: appContext.database,
473
+ })
474
+ rendered = await segment.view.render(
475
+ segment.use,
476
+ segment.compiled,
477
+ viewContext,
478
+ appContext,
479
+ )
480
+ } finally {
481
+ env.perf.record(
482
+ `${segment.view.plugin}.view.render`,
483
+ performance.now() - start,
484
+ `${ctx.source.path} <${segment.view.tag}>`,
485
+ )
486
+ }
487
+ output.push(
488
+ stampComponentRoot(
489
+ stringifyHtml(rendered),
490
+ segment.view.tag,
491
+ env.environment.rewriteHtml,
492
+ ),
493
+ )
494
+ }
495
+ const pageEnd = pageAt + plan.html.length
496
+ if (pageEnd < html.length) output.push(html.slice(pageEnd))
497
+ return output
498
+ }
499
+ }