@effected/templates 0.1.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.
@@ -0,0 +1,104 @@
1
+ import { Schema } from "effect";
2
+
3
+ //#region src/CommentStyle.ts
4
+ /**
5
+ * A comment delimiter: non-empty, and free of control characters.
6
+ *
7
+ * @remarks
8
+ * Every constraint is load-bearing rather than decorative. An empty delimiter
9
+ * would make the marker scanner match every line in a document. A delimiter
10
+ * containing a line break would let a caller inject arbitrary lines into a
11
+ * rendered marker. And a delimiter containing NUL would collide in
12
+ * {@link CommentStyle.id}, where NUL is the field separator — `{ prefix: "a\0b" }`
13
+ * and `{ prefix: "a", suffix: "b" }` would key the same lookup entry. All of
14
+ * them fail at construction.
15
+ *
16
+ * The pattern is a negated character class with a single quantifier, so it
17
+ * cannot backtrack on hostile input.
18
+ */
19
+ const Delimiter = Schema.String.check(Schema.isPattern(/^\P{Cc}+$/u));
20
+ /**
21
+ * How a managed section's markers are commented out in a given file format.
22
+ *
23
+ * @remarks
24
+ * A **line** style carries only a `prefix` (`#`, `//`); a **wrapped** style
25
+ * carries a `suffix` as well (`<!--` … `-->`). The wrapped form is what makes
26
+ * managed sections representable in Markdown, HTML and XML, which the two-way
27
+ * literal union this replaces could not express.
28
+ *
29
+ * The preset set is a convenience, not a closed world: a format nobody
30
+ * anticipated is one `CommentStyle.make({ prefix: "%" })` away.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * import { CommentStyle } from "@effected/templates";
35
+ *
36
+ * CommentStyle.hash; // # …
37
+ * CommentStyle.html; // <!-- … -->
38
+ * CommentStyle.make({ prefix: "(*", suffix: "*)" }); // ML, no preset needed
39
+ * ```
40
+ *
41
+ * @public
42
+ */
43
+ var CommentStyle = class CommentStyle extends Schema.Class("CommentStyle")({
44
+ /** Opens the comment. Non-empty, single-line. */
45
+ prefix: Delimiter,
46
+ /** Closes the comment, for wrapped styles. Omitted for line styles. */
47
+ suffix: Schema.optionalKey(Delimiter)
48
+ }) {
49
+ /** Shell, YAML, TOML, Python, Dockerfile, `.env`. */
50
+ static hash = CommentStyle.make({ prefix: "#" });
51
+ /** JavaScript, TypeScript, C, Go, Rust, JSONC. */
52
+ static slash = CommentStyle.make({ prefix: "//" });
53
+ /** INI, Lisp, assembly. */
54
+ static semicolon = CommentStyle.make({ prefix: ";" });
55
+ /** SQL, Lua, Haskell. */
56
+ static dash = CommentStyle.make({ prefix: "--" });
57
+ /** Markdown, HTML, XML — the wrapped style v3 could not represent. */
58
+ static html = CommentStyle.make({
59
+ prefix: "<!--",
60
+ suffix: "-->"
61
+ });
62
+ /** CSS, and the block form of every C-family language. */
63
+ static block = CommentStyle.make({
64
+ prefix: "/*",
65
+ suffix: "*/"
66
+ });
67
+ /**
68
+ * Every preset, in the order a reader would expect: line styles first,
69
+ * then wrapped.
70
+ *
71
+ * @remarks
72
+ * A grouped static over variants of one concept that live in one module
73
+ * and reach nothing heavier than each other — nothing sits behind a preset
74
+ * but a two-field object, so this is not the namespace-object hazard.
75
+ */
76
+ static presets = [
77
+ CommentStyle.hash,
78
+ CommentStyle.slash,
79
+ CommentStyle.semicolon,
80
+ CommentStyle.dash,
81
+ CommentStyle.html,
82
+ CommentStyle.block
83
+ ];
84
+ /**
85
+ * A stable string identity, for keying a plain `Map`.
86
+ *
87
+ * @remarks
88
+ * The `NUL` separator is not cosmetic: without it `{ prefix: "ab" }`
89
+ * and `{ prefix: "a", suffix: "b" }` would produce the same id and two
90
+ * genuinely different styles would collide in a lookup table. `NUL`
91
+ * cannot occur in a delimiter, which the single-line check guarantees is
92
+ * printable-ish, so the encoding is unambiguous.
93
+ */
94
+ get id() {
95
+ return `${this.prefix}\u0000${this.suffix ?? ""}`;
96
+ }
97
+ /** True when the style wraps its content, i.e. it carries a suffix. */
98
+ get isWrapped() {
99
+ return this.suffix !== void 0;
100
+ }
101
+ };
102
+
103
+ //#endregion
104
+ export { CommentStyle };
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,211 @@
1
+ import { SectionDialect } from "./SectionDialect.js";
2
+ import { SectionDocument, SectionParseError } from "./SectionDocument.js";
3
+ import { Context, Effect, FileSystem, Layer, Schema } from "effect";
4
+
5
+ //#region src/ManagedSection.ts
6
+ /**
7
+ * Raised when the file behind a managed-section operation could not be read or
8
+ * written.
9
+ *
10
+ * @remarks
11
+ * The underlying `PlatformError` is preserved structurally in `cause` rather
12
+ * than stringified. `operation` is what a bare platform error cannot tell a
13
+ * caller: these operations are read-modify-write, so "it failed" is ambiguous
14
+ * about whether the document was even seen.
15
+ *
16
+ * A **missing** file is not an error — it reads as a document with no
17
+ * sections, and a sync creates it.
18
+ *
19
+ * @public
20
+ */
21
+ var SectionFileError = class extends Schema.TaggedErrorClass()("SectionFileError", {
22
+ /** The file the operation was against. */
23
+ path: Schema.String,
24
+ /** Which half of the read-modify-write failed. */
25
+ operation: Schema.Literals(["read", "write"]),
26
+ /** The underlying failure, preserved structurally. */
27
+ cause: Schema.Defect()
28
+ }) {
29
+ get message() {
30
+ return `Failed to ${this.operation} managed sections in "${this.path}"`;
31
+ }
32
+ };
33
+ const notFound = (error) => error.reason._tag === "NotFound";
34
+ /**
35
+ * Decodes file bytes **without** consuming a byte-order mark.
36
+ *
37
+ * @remarks
38
+ * `FileSystem.readFileString` decodes through a default `TextDecoder`, which
39
+ * strips a leading BOM — verified against `@effect/platform-node@4.0.0-beta.101`.
40
+ * Reading through it would make the first sync of a BOM-carrying file silently
41
+ * delete the BOM, which violates this package's central promise that every byte
42
+ * outside a managed span survives. Reading bytes and decoding with
43
+ * `ignoreBOM: true` keeps the mark in the leading text span, where it is
44
+ * preserved like any other content.
45
+ */
46
+ const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
47
+ const make = (options = {}) => Effect.gen(function* () {
48
+ const fs = yield* FileSystem.FileSystem;
49
+ const dialect = options.dialect ?? SectionDialect.default;
50
+ /**
51
+ * Read and parse, degrading a missing file to an empty document.
52
+ *
53
+ * One read, not an `exists` probe followed by a read: two syscalls with a
54
+ * race between them, where the file can appear or vanish in the gap.
55
+ */
56
+ const load = (path) => fs.readFile(path).pipe(Effect.map((bytes) => decoder.decode(bytes)), Effect.catchTag("PlatformError", (error) => notFound(error) ? Effect.succeed("") : Effect.fail(new SectionFileError({
57
+ path,
58
+ operation: "read",
59
+ cause: error
60
+ }))), Effect.flatMap((text) => Effect.fromResult(SectionDocument.parseResult(text, dialect)).pipe(Effect.mapError((error) => SectionParseError.at(path, error)))));
61
+ const store = (path, text) => fs.writeFileString(path, text).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(new SectionFileError({
62
+ path,
63
+ operation: "write",
64
+ cause: error
65
+ }))));
66
+ const syncAll = Effect.fn("ManagedSection.syncAll")(function* (path, sections) {
67
+ yield* Effect.annotateCurrentSpan({
68
+ path,
69
+ sections: sections.length
70
+ });
71
+ const document = yield* load(path);
72
+ const reconciled = yield* Effect.fromResult(document.reconcile(sections));
73
+ if (reconciled.changed) yield* store(path, reconciled.text);
74
+ return reconciled.outcomes;
75
+ });
76
+ return {
77
+ read: Effect.fn("ManagedSection.read")(function* (path, id) {
78
+ yield* Effect.annotateCurrentSpan({
79
+ path,
80
+ key: id.key
81
+ });
82
+ return (yield* load(path)).read(id);
83
+ }),
84
+ readAll: Effect.fn("ManagedSection.readAll")(function* (path) {
85
+ yield* Effect.annotateCurrentSpan({ path });
86
+ return (yield* load(path)).sections.map((placed) => placed.section);
87
+ }),
88
+ isManaged: Effect.fn("ManagedSection.isManaged")(function* (path, id) {
89
+ yield* Effect.annotateCurrentSpan({
90
+ path,
91
+ key: id.key
92
+ });
93
+ return (yield* load(path)).has(id);
94
+ }),
95
+ sync: Effect.fn("ManagedSection.sync")(function* (path, section) {
96
+ yield* Effect.annotateCurrentSpan({
97
+ path,
98
+ key: section.key
99
+ });
100
+ return (yield* syncAll(path, [section]))[0];
101
+ }),
102
+ syncAll,
103
+ check: Effect.fn("ManagedSection.check")(function* (path, section) {
104
+ yield* Effect.annotateCurrentSpan({
105
+ path,
106
+ key: section.key
107
+ });
108
+ return (yield* load(path)).check(section);
109
+ }),
110
+ checkAll: Effect.fn("ManagedSection.checkAll")(function* (path, sections) {
111
+ yield* Effect.annotateCurrentSpan({
112
+ path,
113
+ sections: sections.length
114
+ });
115
+ const document = yield* load(path);
116
+ return sections.map((section) => document.check(section));
117
+ }),
118
+ remove: Effect.fn("ManagedSection.remove")(function* (path, id) {
119
+ yield* Effect.annotateCurrentSpan({
120
+ path,
121
+ key: id.key
122
+ });
123
+ const next = (yield* load(path)).remove(id);
124
+ if (next._tag === "None") return false;
125
+ yield* store(path, next.value);
126
+ return true;
127
+ })
128
+ };
129
+ });
130
+ const unimplemented = (member) => {
131
+ throw new Error(`ManagedSection.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
132
+ };
133
+ /**
134
+ * Managed sections in files: read, compare, sync and remove delimited blocks
135
+ * whose surrounding content belongs to the user.
136
+ *
137
+ * @remarks
138
+ * A thin shell over {@link SectionDocument}: every member reads the file, runs
139
+ * the pure core, and writes back only when the text actually changed. All the
140
+ * interesting behavior — and all the interesting tests — live in the pure core.
141
+ *
142
+ * `FileSystem` is required in `R` and discharged by the consumer's platform
143
+ * layer at the edge. No `Path` is required: paths are handed to `FileSystem`
144
+ * untouched.
145
+ *
146
+ * @example
147
+ * ```ts
148
+ * import { CommentStyle, ManagedSection, SectionId } from "@effected/templates";
149
+ * import { Effect } from "effect";
150
+ *
151
+ * const Base = SectionId.make({ key: "base", commentStyle: CommentStyle.hash });
152
+ * const Tool = SectionId.make({ key: "tool", commentStyle: CommentStyle.hash });
153
+ *
154
+ * const program = Effect.gen(function* () {
155
+ * const sections = yield* ManagedSection;
156
+ * // Declared order is written into the file, so `base` precedes `tool`.
157
+ * return yield* sections.syncAll(".husky/pre-commit", [
158
+ * Base.section("preamble"),
159
+ * Tool.section("run-the-tool"),
160
+ * ]);
161
+ * });
162
+ * ```
163
+ *
164
+ * @public
165
+ */
166
+ var ManagedSection = class ManagedSection extends Context.Service()("@effected/templates/ManagedSection") {
167
+ /**
168
+ * The default layer, reading and writing {@link SectionDialect.default}
169
+ * markers.
170
+ *
171
+ * @remarks
172
+ * A bound `const`, not a function, so it memoizes by reference.
173
+ */
174
+ static layer = Layer.effect(this, make());
175
+ /**
176
+ * A layer with a custom marker dialect.
177
+ *
178
+ * @remarks
179
+ * A parameterized layer factory mints a **fresh reference per call**, and
180
+ * layers memoize by reference — bind the result to a `const` and reuse it
181
+ * rather than calling `layerWith(...)` at each composition site.
182
+ */
183
+ static layerWith = (options) => Layer.effect(ManagedSection, make(options));
184
+ /**
185
+ * A test double. Members a suite does not stub **die when called**, rather
186
+ * than returning a plausible-looking answer that makes a wrong test pass.
187
+ */
188
+ static makeTest = (overrides = {}) => ({
189
+ read: () => Effect.sync(() => unimplemented("read")),
190
+ readAll: () => Effect.sync(() => unimplemented("readAll")),
191
+ isManaged: () => Effect.sync(() => unimplemented("isManaged")),
192
+ sync: () => Effect.sync(() => unimplemented("sync")),
193
+ syncAll: () => Effect.sync(() => unimplemented("syncAll")),
194
+ check: () => Effect.sync(() => unimplemented("check")),
195
+ checkAll: () => Effect.sync(() => unimplemented("checkAll")),
196
+ remove: () => Effect.sync(() => unimplemented("remove")),
197
+ ...overrides
198
+ });
199
+ /**
200
+ * The test layer: {@link ManagedSection.makeTest} behind `Layer.succeed`, so
201
+ * a suite provides only the members it exercises.
202
+ *
203
+ * @remarks
204
+ * A parameterized layer factory mints a fresh reference per call — bind it
205
+ * to a `const` rather than calling it at each composition site.
206
+ */
207
+ static layerTest = (overrides = {}) => Layer.succeed(ManagedSection, ManagedSection.makeTest(overrides));
208
+ };
209
+
210
+ //#endregion
211
+ export { ManagedSection, SectionFileError };
package/Section.js ADDED
@@ -0,0 +1,126 @@
1
+ import { CommentStyle } from "./CommentStyle.js";
2
+ import { Schema } from "effect";
3
+
4
+ //#region src/Section.ts
5
+ /**
6
+ * The name identifying a managed section, as it literally appears in the
7
+ * file's markers.
8
+ *
9
+ * @remarks
10
+ * Keys are **case-sensitive** and rendered verbatim: the key a consumer
11
+ * declares is the key the marker carries. A file already containing
12
+ * `SAVVY-LINT` markers is managed by declaring the key `"SAVVY-LINT"`.
13
+ *
14
+ * The character set is bounded so a key can always be read back out of a
15
+ * marker unambiguously — whitespace in particular would make the scanner's
16
+ * key capture ambiguous against the marker phrase that follows it.
17
+ *
18
+ * This is a checked string rather than a branded one **deliberately**: a
19
+ * brand would force every call site through a decode step to spell
20
+ * `SectionId.make({ key: "example-tool", … })`, and the validation a brand
21
+ * would carry is already enforced by the check at construction.
22
+ *
23
+ * @public
24
+ */
25
+ const SectionKey = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/));
26
+ /**
27
+ * What identifies a managed section inside a document: its key and the
28
+ * comment style its markers are written in.
29
+ *
30
+ * @remarks
31
+ * `commentStyle` is **required, with no default**. A defaulted style is how a
32
+ * caller who forgets the argument writes `#` markers into a TypeScript file —
33
+ * a syntax error in the user's own source, produced silently by an omission.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * import { CommentStyle, SectionId } from "@effected/templates";
38
+ *
39
+ * const ToolSection = SectionId.make({
40
+ * key: "example-tool",
41
+ * commentStyle: CommentStyle.hash,
42
+ * });
43
+ *
44
+ * const block = ToolSection.section("echo hello");
45
+ * ```
46
+ *
47
+ * @public
48
+ */
49
+ var SectionId = class extends Schema.Class("SectionId")({
50
+ /** The section's name, exactly as it appears in the markers. */
51
+ key: SectionKey,
52
+ /** How this section's markers are commented out. */
53
+ commentStyle: CommentStyle
54
+ }) {
55
+ /** Pair this identity with the content a tool wants inside it. */
56
+ section(content) {
57
+ return Section.make({
58
+ key: this.key,
59
+ commentStyle: this.commentStyle,
60
+ content
61
+ });
62
+ }
63
+ };
64
+ /**
65
+ * A managed section: an identity plus the content its owner wants between the
66
+ * markers.
67
+ *
68
+ * @remarks
69
+ * Equality is **structural and whitespace-significant**. The v3 model this
70
+ * replaces compared trimmed, whitespace-collapsed content, which silently
71
+ * swallowed any template change that altered only indentation — the change
72
+ * compared equal, reported `Unchanged`, and never reached the file. The one
73
+ * normalization this package applies is to line endings, and it happens at
74
+ * parse time rather than inside equality, so `Equal.equals` stays honest for
75
+ * a consumer comparing two sections directly.
76
+ *
77
+ * @public
78
+ */
79
+ var Section = class Section extends Schema.Class("Section")({
80
+ /** The section's name, exactly as it appears in the markers. */
81
+ key: SectionKey,
82
+ /** How this section's markers are commented out. */
83
+ commentStyle: CommentStyle,
84
+ /** Everything between the markers, exclusive of the boundary line breaks. */
85
+ content: Schema.String
86
+ }) {
87
+ /** This section's identity, without its content. */
88
+ get id() {
89
+ return SectionId.make({
90
+ key: this.key,
91
+ commentStyle: this.commentStyle
92
+ });
93
+ }
94
+ /** The same section carrying different content. */
95
+ withContent(content) {
96
+ return Section.make({
97
+ key: this.key,
98
+ commentStyle: this.commentStyle,
99
+ content
100
+ });
101
+ }
102
+ };
103
+ /**
104
+ * A managed section as found in a document, carrying the span it occupies.
105
+ *
106
+ * @remarks
107
+ * `start` and `end` bound the **whole** block, from the first character of
108
+ * the begin marker to one past the last character of the end marker, so
109
+ * `text.slice(start, end)` is exactly the block as written. `line` is 1-based
110
+ * and points at the begin marker, which is what a diagnostic needs.
111
+ *
112
+ * @public
113
+ */
114
+ var PlacedSection = class extends Schema.Class("PlacedSection")({
115
+ /** The section, with line endings already normalized to `\n`. */
116
+ section: Section,
117
+ /** Offset of the begin marker's first character. */
118
+ start: Schema.Number,
119
+ /** Offset one past the end marker's last character. */
120
+ end: Schema.Number,
121
+ /** 1-based line of the begin marker. */
122
+ line: Schema.Number
123
+ }) {};
124
+
125
+ //#endregion
126
+ export { PlacedSection, Section, SectionId, SectionKey };
@@ -0,0 +1,164 @@
1
+ import { CommentStyle } from "./CommentStyle.js";
2
+ import { Equal, Result, Schema } from "effect";
3
+
4
+ //#region src/SectionDialect.ts
5
+ /**
6
+ * Raised when a section cannot be turned into marker-delimited text.
7
+ *
8
+ * @remarks
9
+ * Both reasons describe a document this package would be unable to read back
10
+ * correctly, so rendering refuses rather than writing something it cannot
11
+ * re-parse.
12
+ *
13
+ * @public
14
+ */
15
+ var SectionRenderError = class extends Schema.TaggedErrorClass()("SectionRenderError", {
16
+ /**
17
+ * `markerInContent` — the content carries a line the scanner would read as
18
+ * a marker, which would move the block boundary and let the next sync
19
+ * consume user text. `unknownCommentStyle` — the section's comment style
20
+ * is not in the dialect's set, so the block would be written into a
21
+ * document where the scanner could never find it again, growing a
22
+ * duplicate on every run. `duplicateDeclaration` — the same identity was
23
+ * declared twice in one call, so the caller stated two intentions for one
24
+ * block and any choice between them would be a guess; this is the
25
+ * caller-side twin of the document-side `duplicateSection`.
26
+ */
27
+ reason: Schema.Literals([
28
+ "markerInContent",
29
+ "unknownCommentStyle",
30
+ "duplicateDeclaration"
31
+ ]),
32
+ /** The key of the section that could not be rendered. */
33
+ key: Schema.String
34
+ }) {
35
+ get message() {
36
+ switch (this.reason) {
37
+ case "markerInContent": return `Section "${this.key}" has content containing a managed-section marker`;
38
+ case "unknownCommentStyle": return `Section "${this.key}" uses a comment style this dialect does not recognize`;
39
+ default: return `Section "${this.key}" was declared twice in one call`;
40
+ }
41
+ }
42
+ };
43
+ /** The key grammar, mirrored from `SectionKey` for the scanner's capture group. */
44
+ const KEY_CAPTURE = "([A-Za-z0-9][A-Za-z0-9._-]*)";
45
+ const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
+ /** Inter-token whitespace is tolerated on read, normalized on write. */
47
+ const GAP = "[ \\t]+";
48
+ /**
49
+ * Compiled matchers are cached per dialect instance. A `WeakMap` rather than
50
+ * a field keeps `SectionDialect` a pure schema class — v3 hung a non-schema
51
+ * private field off its definition class and had to hand-copy it on every
52
+ * derivation.
53
+ */
54
+ const matcherCache = /* @__PURE__ */ new WeakMap();
55
+ /**
56
+ * The marker vocabulary: what phrase delimits a managed section, and which
57
+ * comment styles a document is scanned for.
58
+ *
59
+ * @remarks
60
+ * `styles` exists because reconciliation must **recognize sections it does
61
+ * not own** — a foreign tool's block in the same file is preserved verbatim
62
+ * and must never be mistaken for prose. That set cannot be derived from the
63
+ * caller's own sections, because a foreign block's style may appear nowhere
64
+ * in them.
65
+ *
66
+ * One phrase per dialect is deliberate: two marker families in one document
67
+ * make parsing ambiguous for no benefit anyone has asked for.
68
+ *
69
+ * @public
70
+ */
71
+ var SectionDialect = class SectionDialect extends Schema.Class("SectionDialect")({
72
+ /**
73
+ * The phrase between the key and the closing rule.
74
+ *
75
+ * @remarks
76
+ * Letters, digits, spaces and underscores only. Dashes are excluded so a
77
+ * phrase can never contain the `---` rule and make a marker ambiguous
78
+ * against itself.
79
+ */
80
+ phrase: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9 _]*$/)),
81
+ /** Which comment styles the document scanner recognizes. At least one. */
82
+ styles: Schema.Array(CommentStyle).check(Schema.isMinLength(1))
83
+ }) {
84
+ /**
85
+ * The zero-configuration dialect: the phrase `MANAGED SECTION` and every
86
+ * preset comment style.
87
+ */
88
+ static default = SectionDialect.make({
89
+ phrase: "MANAGED SECTION",
90
+ styles: CommentStyle.presets
91
+ });
92
+ /** True when a section written in this style can be scanned back. */
93
+ recognizes(style) {
94
+ return this.styles.some((candidate) => Equal.equals(candidate, style));
95
+ }
96
+ /** The opening marker line for an identity, without a line break. */
97
+ beginMarker(id) {
98
+ return this.marker("BEGIN", id);
99
+ }
100
+ /** The closing marker line for an identity, without a line break. */
101
+ endMarker(id) {
102
+ return this.marker("END", id);
103
+ }
104
+ /**
105
+ * A section as marker-delimited text, or a typed refusal.
106
+ *
107
+ * @remarks
108
+ * Fails rather than producing a document it could not read back: see
109
+ * {@link SectionRenderError}. Content is emitted with `eol` throughout,
110
+ * so a section rendered into a CRLF document stays CRLF.
111
+ */
112
+ render(section, eol = "\n") {
113
+ if (!this.recognizes(section.commentStyle)) return Result.fail(new SectionRenderError({
114
+ reason: "unknownCommentStyle",
115
+ key: section.key
116
+ }));
117
+ if (this.containsMarker(section.content)) return Result.fail(new SectionRenderError({
118
+ reason: "markerInContent",
119
+ key: section.key
120
+ }));
121
+ const body = eol === "\n" ? section.content : section.content.replace(/\n/g, eol);
122
+ return Result.succeed(`${this.beginMarker(section.id)}${eol}${body}${eol}${this.endMarker(section.id)}`);
123
+ }
124
+ /**
125
+ * True when `text` contains a line this dialect would read as a marker.
126
+ *
127
+ * @internal
128
+ */
129
+ containsMarker(text) {
130
+ return this.matchers().some((matcher) => text.matchAll(matcher.regex).next().done === false);
131
+ }
132
+ /**
133
+ * One compiled scanner per recognized comment style, memoized.
134
+ *
135
+ * @remarks
136
+ * Each pattern is anchored per line, bounds the key with an explicit
137
+ * character class, and carries no nested quantifier, so scanning is linear
138
+ * in document length. Every caller-supplied fragment — prefix, suffix,
139
+ * phrase — is regex-escaped before interpolation.
140
+ *
141
+ * @internal
142
+ */
143
+ matchers() {
144
+ const cached = matcherCache.get(this);
145
+ if (cached !== void 0) return cached;
146
+ const phrase = escapeRegex(this.phrase).replace(/ +/g, GAP);
147
+ const compiled = this.styles.map((style) => {
148
+ const tail = style.suffix === void 0 ? "" : `${GAP}${escapeRegex(style.suffix)}`;
149
+ return {
150
+ style,
151
+ regex: new RegExp(`^${escapeRegex(style.prefix)}${GAP}---${GAP}(BEGIN|END)${GAP}${KEY_CAPTURE}${GAP}${phrase}${GAP}---${tail}[ \\t]*(?=\r?$)`, "gm")
152
+ };
153
+ });
154
+ matcherCache.set(this, compiled);
155
+ return compiled;
156
+ }
157
+ marker(kind, id) {
158
+ const tail = id.commentStyle.suffix === void 0 ? "" : ` ${id.commentStyle.suffix}`;
159
+ return `${id.commentStyle.prefix} --- ${kind} ${id.key} ${this.phrase} ---${tail}`;
160
+ }
161
+ };
162
+
163
+ //#endregion
164
+ export { SectionDialect, SectionRenderError };