@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.
- package/LICENSE.md +172 -0
- package/README.md +111 -0
- package/actions/actions.ts +307 -0
- package/actions/draft.ts +105 -0
- package/actions/transaction.ts +164 -0
- package/assets.ts +149 -0
- package/authority/access.ts +820 -0
- package/authority/actors.ts +271 -0
- package/authority/agents.ts +95 -0
- package/authority/credentials.ts +144 -0
- package/authority/model.ts +71 -0
- package/authority/sharing.ts +89 -0
- package/browser-bundle.ts +61 -0
- package/canonical.ts +41 -0
- package/cli/dev.ts +134 -0
- package/cli/export.ts +74 -0
- package/cli/index.ts +52 -0
- package/cli/lint.ts +164 -0
- package/cli/mv.ts +30 -0
- package/cli/perms.ts +182 -0
- package/cli/plugin-packages.ts +159 -0
- package/cli/plugins.ts +95 -0
- package/cli/query.ts +225 -0
- package/cli/readme.md +95 -0
- package/cli/release.ts +75 -0
- package/cli/rm.ts +22 -0
- package/cli/serve.ts +100 -0
- package/cli/tree.ts +131 -0
- package/cli/update.ts +78 -0
- package/cli/utils.ts +53 -0
- package/core.ts +96 -0
- package/database.ts +189 -0
- package/dependencies.ts +60 -0
- package/dist/pathmx.js +1224 -0
- package/document.ts +194 -0
- package/environment.ts +110 -0
- package/environments/bun-plugins.ts +68 -0
- package/environments/bun-publish.ts +194 -0
- package/environments/bun.ts +223 -0
- package/globals.d.ts +14 -0
- package/graph.ts +153 -0
- package/host/compiler.ts +314 -0
- package/host/create.ts +66 -0
- package/host/engine.ts +630 -0
- package/host/plugin-host.ts +487 -0
- package/host/render.ts +102 -0
- package/host/response.ts +20 -0
- package/host/shell.html +15 -0
- package/host/source-sync.ts +254 -0
- package/html-attributes.ts +15 -0
- package/html.ts +48 -0
- package/icons/LICENSE +43 -0
- package/icons/lucide.ts +54 -0
- package/index.ts +75 -0
- package/invalidation.ts +85 -0
- package/log.ts +84 -0
- package/ops/href.ts +87 -0
- package/ops/index.ts +9 -0
- package/ops/move-source.ts +114 -0
- package/ops/plan.ts +47 -0
- package/ops/remove-source.ts +70 -0
- package/package.json +46 -0
- package/path.ts +75 -0
- package/pattern.ts +314 -0
- package/perf.ts +88 -0
- package/plugins/actor-path.ts +43 -0
- package/plugins/actor-profile.ts +55 -0
- package/plugins/agent-browsing.ts +183 -0
- package/plugins/app-component.ts +23 -0
- package/plugins/block-layout/index.ts +78 -0
- package/plugins/block-layout/theme.css +90 -0
- package/plugins/callout/index.ts +236 -0
- package/plugins/callout/theme.css +138 -0
- package/plugins/canonical-links.ts +144 -0
- package/plugins/code-highlight/client.ts +89 -0
- package/plugins/code-highlight/index.ts +147 -0
- package/plugins/code-highlight/theme.css +89 -0
- package/plugins/context.ts +131 -0
- package/plugins/datatype/Datatype[wdth,wght].woff2 +0 -0
- package/plugins/datatype/OFL.txt +96 -0
- package/plugins/datatype/index.ts +287 -0
- package/plugins/definition-list.ts +236 -0
- package/plugins/directive-definitions.ts +13 -0
- package/plugins/embedding/client.ts +88 -0
- package/plugins/embedding/index.ts +43 -0
- package/plugins/image/index.ts +68 -0
- package/plugins/image/theme.css +43 -0
- package/plugins/include.ts +119 -0
- package/plugins/index.ts +91 -0
- package/plugins/input/authoring.ts +236 -0
- package/plugins/input/config.ts +85 -0
- package/plugins/input/index.ts +359 -0
- package/plugins/input/model.ts +173 -0
- package/plugins/input/state.ts +272 -0
- package/plugins/layout.ts +313 -0
- package/plugins/literate/client.ts +208 -0
- package/plugins/literate/index.ts +742 -0
- package/plugins/math/index.ts +140 -0
- package/plugins/meta.ts +42 -0
- package/plugins/navigation/index.ts +158 -0
- package/plugins/navigation/model.ts +364 -0
- package/plugins/preset.ts +65 -0
- package/plugins/query/index.ts +322 -0
- package/plugins/rss/index.ts +46 -0
- package/plugins/runtime/actions.ts +62 -0
- package/plugins/runtime/client.ts +8 -0
- package/plugins/runtime/document.ts +506 -0
- package/plugins/runtime/error.ts +154 -0
- package/plugins/runtime/heading-links.ts +31 -0
- package/plugins/runtime/index.ts +104 -0
- package/plugins/runtime/live.ts +51 -0
- package/plugins/runtime/navigation.ts +113 -0
- package/plugins/runtime/pending.ts +53 -0
- package/plugins/runtime/publication.ts +19 -0
- package/plugins/runtime/start.ts +177 -0
- package/plugins/runtime/static-client.ts +6 -0
- package/plugins/runtime/static-live.ts +188 -0
- package/plugins/runtime/view-transition.ts +72 -0
- package/plugins/runtime/viewport.ts +110 -0
- package/plugins/schema.ts +24 -0
- package/plugins/scripts.ts +140 -0
- package/plugins/source-preview/client.ts +256 -0
- package/plugins/source-preview/index.ts +132 -0
- package/plugins/source-preview/model.ts +11 -0
- package/plugins/source-preview/preview.css +107 -0
- package/plugins/styles.ts +145 -0
- package/plugins/theme/components.ts +10 -0
- package/plugins/theme/foundation.css +358 -0
- package/plugins/theme/index.ts +333 -0
- package/plugins/theme/prose.css +284 -0
- package/plugins/theme/scheme.js +63 -0
- package/plugins/theme/toggle.css +44 -0
- package/plugins/theme/view-transition.css +68 -0
- package/plugins/toc/client.ts +146 -0
- package/plugins/toc/index.ts +232 -0
- package/plugins/toc/theme.css +172 -0
- package/plugins/types.ts +538 -0
- package/problem.ts +41 -0
- package/project.ts +6 -0
- package/publish/build.ts +402 -0
- package/publish/concurrent.ts +32 -0
- package/publish/identity.ts +63 -0
- package/publish/index.ts +11 -0
- package/publish/location.ts +167 -0
- package/publish/model.ts +58 -0
- package/publish/plan.ts +300 -0
- package/publish/validate.ts +150 -0
- package/query/candidate-index.ts +101 -0
- package/query/dependency-index.ts +99 -0
- package/query/fields.ts +216 -0
- package/query/invalidation.ts +48 -0
- package/query/kernel.ts +776 -0
- package/query/model.ts +79 -0
- package/repository.ts +374 -0
- package/runtime-profile.ts +34 -0
- package/schema/model.ts +92 -0
- package/schema/registry.ts +642 -0
- package/schema/standard.ts +226 -0
- package/server/context.ts +94 -0
- package/server/css-imports.ts +66 -0
- package/server/http.ts +333 -0
- package/server/page.ts +21 -0
- package/server/protocols.ts +12 -0
- package/server/representations.ts +66 -0
- package/server/router.ts +131 -0
- package/server/routes.ts +202 -0
- package/server/serve.ts +5 -0
- package/server/watch.ts +120 -0
- package/source/blocks.ts +71 -0
- package/source/format.ts +164 -0
- package/source/index.ts +67 -0
- package/source/interpolate.ts +63 -0
- package/source/markdown/blocks.ts +46 -0
- package/source/markdown/comments.ts +32 -0
- package/source/markdown/fences.ts +67 -0
- package/source/markdown/headings.ts +88 -0
- package/source/markdown/index.ts +34 -0
- package/source/markdown/inline-code.ts +6 -0
- package/source/markdown/links.ts +326 -0
- package/source/markdown/lists.ts +223 -0
- package/source/markdown/prose.ts +74 -0
- package/source/markdown/scan.ts +79 -0
- package/source/markdown/text.ts +82 -0
- package/source/meta.ts +87 -0
- package/source/source.ts +121 -0
- package/source/topmatter.ts +57 -0
- package/source/utils.ts +21 -0
- package/source-diagnostic.ts +63 -0
- package/text-file.ts +26 -0
- package/util.ts +35 -0
- package/view/components.ts +499 -0
- package/view/model.ts +91 -0
- package/view/projection.ts +162 -0
- package/view/session.ts +146 -0
- 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
|
+
}
|
package/source/utils.ts
ADDED
|
@@ -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, "&")
|
|
10
|
+
.replace(/</g, "<")
|
|
11
|
+
.replace(/>/g, ">")
|
|
12
|
+
.replace(/"/g, """)
|
|
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(/</g, "<")
|
|
20
|
+
.replace(/>/g, ">")
|
|
21
|
+
.replace(/"/g, '"')
|
|
22
|
+
.replace(/'|'/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(/&/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
|
+
}
|