@effected/templates 0.3.0 → 0.4.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/README.md CHANGED
@@ -65,11 +65,40 @@ Effect.runPromise(program.pipe(Effect.provide(ManagedSection.layer), Effect.prov
65
65
  // example output on a second, identical run: [ "Unchanged", "Unchanged" ]
66
66
  ```
67
67
 
68
+ ## Marker attributes
69
+
70
+ A `BEGIN` marker can carry `name="value"` pairs, so a tool reads a block's provenance — which run wrote it, and when — off the marker line without opening the block:
71
+
72
+ ```ts
73
+ import { CommentStyle, SectionDocument, SectionId } from "@effected/templates";
74
+ import { Result } from "effect";
75
+
76
+ const Tool = SectionId.make({ key: "tool", commentStyle: CommentStyle.hash });
77
+ const doc = SectionDocument.parseResult("#!/usr/bin/env sh\n");
78
+
79
+ if (Result.isSuccess(doc)) {
80
+ const next = doc.success.reconcile([Tool.section("npx tool run", { origin: "ci", runId: "1873" })]);
81
+ console.log(Result.isSuccess(next) ? next.success.text : next.failure.message);
82
+ // #!/usr/bin/env sh
83
+ //
84
+ // # --- BEGIN tool MANAGED SECTION origin="ci" runId="1873" ---
85
+ // npx tool run
86
+ // # --- END tool MANAGED SECTION ---
87
+ }
88
+ ```
89
+
90
+ Attributes are metadata, never identity: a section is found by key and comment style alone, so bumping `runId` updates that block in place instead of orphaning it and appending a second one. They do count as difference — `check` answers `Drifted` when a declared attribute disagrees with the marker on disk, which is what lets a tool rewrite a block whose only change is its metadata. Omitting the argument and passing `{}` are the same section, so a caller that never uses attributes sees no drift against a bare marker.
91
+
92
+ The grammar has no escaping, deliberately: names match `[A-Za-z][A-Za-z0-9_-]*`, values are double-quoted and carry neither `"` nor a line break, and anything outside that fails typed as a `SectionRenderError` with `reason: "invalidAttribute"` naming the attribute — before a byte is written. An `END` marker never carries attributes, and an attribute run that does not parse cleanly (a mangled pair, one name declared twice) makes the whole line ordinary content rather than a marker.
93
+
94
+ Attributed markers are a one-way compatibility break: a scanner predating this feature does not recognize one at all, so the block it opens stops being a managed section for that tool. Writing attributes into a file another tool also manages is a decision, not a detail.
95
+
68
96
  ## Features
69
97
 
70
98
  - `SectionDocument` — the pure core: `parse` / `read` / `has` / `check` / `reconcile` / `remove` over a plain string, with no `Effect`, no IO and no runtime needed to test it.
71
99
  - `ManagedSection` — the service: `read`, `readAll`, `isManaged`, `sync`, `syncAll`, `check`, `checkAll` and `remove`, each writing back only when the text changed.
72
100
  - `CommentStyle` — an open set of `{prefix, suffix?}` presets (`hash`, `slash`, `semicolon`, `dash`, `html`, `block`) covering both line comments and wrapped ones like `<!-- … -->`, so a managed section can live in Markdown or HTML, not just source files.
101
+ - Marker attributes — `name="value"` pairs on a `BEGIN` marker, via `SectionId.section(content, attributes)` and `Section.attributes`: metadata for the tool, never part of a section's identity.
73
102
  - `SyncOutcome` / `CheckOutcome` — tagged results (`Created` / `Updated` / `Unchanged`, `Absent` / `UpToDate` / `Drifted`) that carry the sections involved rather than a diff, so a caller renders exactly the comparison it needs.
74
103
  - Byte-preserving outside managed blocks: every character outside a declared section's span survives a sync, in order, including a leading BOM.
75
104
  - Idempotent by construction: syncing an already-up-to-date document writes nothing and touches no mtime.
package/Section.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CommentStyle } from "./CommentStyle.js";
2
- import { Schema } from "effect";
2
+ import { Effect, Schema } from "effect";
3
3
 
4
4
  //#region src/Section.ts
5
5
  /**
@@ -52,12 +52,20 @@ var SectionId = class extends Schema.Class("SectionId")({
52
52
  /** How this section's markers are commented out. */
53
53
  commentStyle: CommentStyle
54
54
  }) {
55
- /** Pair this identity with the content a tool wants inside it. */
56
- section(content) {
55
+ /**
56
+ * Pair this identity with the content a tool wants inside it.
57
+ *
58
+ * @remarks
59
+ * `attributes` become `name="value"` pairs on the section's BEGIN marker,
60
+ * emitted in the record's insertion order. They are metadata, not identity:
61
+ * see {@link Section} for the grammar and the equality rules.
62
+ */
63
+ section(content, attributes) {
57
64
  return Section.make({
58
65
  key: this.key,
59
66
  commentStyle: this.commentStyle,
60
- content
67
+ content,
68
+ ...attributes === void 0 ? {} : { attributes }
61
69
  });
62
70
  }
63
71
  };
@@ -74,6 +82,21 @@ var SectionId = class extends Schema.Class("SectionId")({
74
82
  * parse time rather than inside equality, so `Equal.equals` stays honest for
75
83
  * a consumer comparing two sections directly.
76
84
  *
85
+ * `attributes` are `name="value"` pairs carried on the BEGIN marker — metadata
86
+ * a tool wants readable from the marker line itself, without opening the
87
+ * content. They **participate in equality** (an attribute change is real
88
+ * drift) but **never in identity**: the section a marker names is decided by
89
+ * key and comment style alone, so changing an attribute updates a block in
90
+ * place rather than orphaning it. Equality over attributes is record equality
91
+ * — order-insensitive, so two sections carrying the same pairs in different
92
+ * insertion orders compare equal — while an actual rewrite renders the pairs
93
+ * in the record's insertion order. Omitting the field and passing `{}` are
94
+ * the same section: the constructor defaults to an empty record.
95
+ *
96
+ * Attribute names must match `[A-Za-z][A-Za-z0-9_-]*` and values may not
97
+ * contain `"` or a line break; violations fail typed at render, not here, so
98
+ * runtime data can never turn construction into a defect.
99
+ *
77
100
  * @public
78
101
  */
79
102
  var Section = class Section extends Schema.Class("Section")({
@@ -82,7 +105,9 @@ var Section = class Section extends Schema.Class("Section")({
82
105
  /** How this section's markers are commented out. */
83
106
  commentStyle: CommentStyle,
84
107
  /** Everything between the markers, exclusive of the boundary line breaks. */
85
- content: Schema.String
108
+ content: Schema.String,
109
+ /** The BEGIN marker's `name="value"` pairs. Empty when the marker carries none. */
110
+ attributes: Schema.Record(Schema.String, Schema.String).pipe(Schema.withConstructorDefault(Effect.succeed({})))
86
111
  }) {
87
112
  /** This section's identity, without its content. */
88
113
  get id() {
@@ -91,12 +116,13 @@ var Section = class Section extends Schema.Class("Section")({
91
116
  commentStyle: this.commentStyle
92
117
  });
93
118
  }
94
- /** The same section carrying different content. */
119
+ /** The same section carrying different content. Attributes are preserved. */
95
120
  withContent(content) {
96
121
  return Section.make({
97
122
  key: this.key,
98
123
  commentStyle: this.commentStyle,
99
- content
124
+ content,
125
+ attributes: this.attributes
100
126
  });
101
127
  }
102
128
  };
package/SectionDialect.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CommentStyle } from "./CommentStyle.js";
2
+ import { ATTRIBUTE_NAME_PATTERN, isValidAttributeValue, parseAttributeRun } from "./internal/attributes.js";
2
3
  import { Equal, Result, Schema } from "effect";
3
4
 
4
5
  //#region src/SectionDialect.ts
@@ -23,19 +24,27 @@ var SectionRenderError = class extends Schema.TaggedError()("SectionRenderError"
23
24
  * declared twice in one call, so the caller stated two intentions for one
24
25
  * block and any choice between them would be a guess; this is the
25
26
  * caller-side twin of the document-side `duplicateSection`.
27
+ * `invalidAttribute` — an attribute's name is outside the
28
+ * `[A-Za-z][A-Za-z0-9_-]*` grammar, or its value contains `"` or a line
29
+ * break; either would render a marker the scanner could not read back
30
+ * verbatim, and there is no escaping mechanism by design.
26
31
  */
27
32
  reason: Schema.Literals([
28
33
  "markerInContent",
29
34
  "unknownCommentStyle",
30
- "duplicateDeclaration"
35
+ "duplicateDeclaration",
36
+ "invalidAttribute"
31
37
  ]),
32
38
  /** The key of the section that could not be rendered. */
33
- key: Schema.String
39
+ key: Schema.String,
40
+ /** The offending attribute's name, when the refusal names one. */
41
+ attribute: Schema.optionalKey(Schema.String)
34
42
  }) {
35
43
  get message() {
36
44
  switch (this.reason) {
37
45
  case "markerInContent": return `Section "${this.key}" has content containing a managed-section marker`;
38
46
  case "unknownCommentStyle": return `Section "${this.key}" uses a comment style this dialect does not recognize`;
47
+ case "invalidAttribute": return this.attribute === void 0 ? `Section "${this.key}" declares an attribute with an invalid name or value` : `Section "${this.key}" declares attribute "${this.attribute}" with an invalid name or value`;
39
48
  default: return `Section "${this.key}" was declared twice in one call`;
40
49
  }
41
50
  }
@@ -114,12 +123,18 @@ var SectionDialect = class SectionDialect extends Schema.Class("SectionDialect")
114
123
  reason: "unknownCommentStyle",
115
124
  key: section.key
116
125
  }));
126
+ for (const [name, value] of Object.entries(section.attributes)) if (!ATTRIBUTE_NAME_PATTERN.test(name) || !isValidAttributeValue(value)) return Result.fail(new SectionRenderError({
127
+ reason: "invalidAttribute",
128
+ key: section.key,
129
+ attribute: name
130
+ }));
117
131
  if (this.containsMarker(section.content)) return Result.fail(new SectionRenderError({
118
132
  reason: "markerInContent",
119
133
  key: section.key
120
134
  }));
121
135
  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)}`);
136
+ const begin = this.marker("BEGIN", section.id, section.attributes);
137
+ return Result.succeed(`${begin}${eol}${body}${eol}${this.endMarker(section.id)}`);
123
138
  }
124
139
  /**
125
140
  * True when `text` contains a line this dialect would read as a marker.
@@ -127,7 +142,12 @@ var SectionDialect = class SectionDialect extends Schema.Class("SectionDialect")
127
142
  * @internal
128
143
  */
129
144
  containsMarker(text) {
130
- return this.matchers().some((matcher) => text.matchAll(matcher.regex).next().done === false);
145
+ for (const matcher of this.matchers()) for (const match of text.matchAll(matcher.regex)) {
146
+ const run = match[3];
147
+ if (run === void 0) return true;
148
+ if (match[1] !== "END" && parseAttributeRun(run) !== void 0) return true;
149
+ }
150
+ return false;
131
151
  }
132
152
  /**
133
153
  * One compiled scanner per recognized comment style, memoized.
@@ -148,15 +168,16 @@ var SectionDialect = class SectionDialect extends Schema.Class("SectionDialect")
148
168
  const tail = style.suffix === void 0 ? "" : `${GAP}${escapeRegex(style.suffix)}`;
149
169
  return {
150
170
  style,
151
- regex: new RegExp(`^${escapeRegex(style.prefix)}${GAP}---${GAP}(BEGIN|END)${GAP}${KEY_CAPTURE}${GAP}${phrase}${GAP}---${tail}[ \\t]*(?=\r?$)`, "gm")
171
+ regex: new RegExp(`^${escapeRegex(style.prefix)}${GAP}---${GAP}(BEGIN|END)${GAP}${KEY_CAPTURE}${GAP}${phrase}(?:${GAP}([^ \\t\\r\\n][^\\r\\n]*?))?${GAP}---${tail}[ \\t]*(?=\r?$)`, "gm")
152
172
  };
153
173
  });
154
174
  matcherCache.set(this, compiled);
155
175
  return compiled;
156
176
  }
157
- marker(kind, id) {
177
+ marker(kind, id, attributes) {
158
178
  const tail = id.commentStyle.suffix === void 0 ? "" : ` ${id.commentStyle.suffix}`;
159
- return `${id.commentStyle.prefix} --- ${kind} ${id.key} ${this.phrase} ---${tail}`;
179
+ const run = attributes === void 0 ? "" : Object.entries(attributes).map(([name, value]) => ` ${name}="${value}"`).join("");
180
+ return `${id.commentStyle.prefix} --- ${kind} ${id.key} ${this.phrase}${run} ---${tail}`;
160
181
  }
161
182
  };
162
183
 
package/index.d.ts CHANGED
@@ -119,8 +119,15 @@ declare const SectionId_base: Schema.Class<SectionId, Schema.Struct<{
119
119
  * @public
120
120
  */
121
121
  declare class SectionId extends SectionId_base {
122
- /** Pair this identity with the content a tool wants inside it. */
123
- section(content: string): Section;
122
+ /**
123
+ * Pair this identity with the content a tool wants inside it.
124
+ *
125
+ * @remarks
126
+ * `attributes` become `name="value"` pairs on the section's BEGIN marker,
127
+ * emitted in the record's insertion order. They are metadata, not identity:
128
+ * see {@link Section} for the grammar and the equality rules.
129
+ */
130
+ section(content: string, attributes?: Readonly<Record<string, string>>): Section;
124
131
  }
125
132
  declare const Section_base: Schema.Class<Section, Schema.Struct<{
126
133
  /** The section's name, exactly as it appears in the markers. */
@@ -129,6 +136,8 @@ declare const Section_base: Schema.Class<Section, Schema.Struct<{
129
136
  readonly commentStyle: typeof CommentStyle;
130
137
  /** Everything between the markers, exclusive of the boundary line breaks. */
131
138
  readonly content: Schema.String;
139
+ /** The BEGIN marker's `name="value"` pairs. Empty when the marker carries none. */
140
+ readonly attributes: Schema.withConstructorDefault<Schema.$Record<Schema.String, Schema.String>>;
132
141
  }>, {}>;
133
142
  /**
134
143
  * A managed section: an identity plus the content its owner wants between the
@@ -143,12 +152,27 @@ declare const Section_base: Schema.Class<Section, Schema.Struct<{
143
152
  * parse time rather than inside equality, so `Equal.equals` stays honest for
144
153
  * a consumer comparing two sections directly.
145
154
  *
155
+ * `attributes` are `name="value"` pairs carried on the BEGIN marker — metadata
156
+ * a tool wants readable from the marker line itself, without opening the
157
+ * content. They **participate in equality** (an attribute change is real
158
+ * drift) but **never in identity**: the section a marker names is decided by
159
+ * key and comment style alone, so changing an attribute updates a block in
160
+ * place rather than orphaning it. Equality over attributes is record equality
161
+ * — order-insensitive, so two sections carrying the same pairs in different
162
+ * insertion orders compare equal — while an actual rewrite renders the pairs
163
+ * in the record's insertion order. Omitting the field and passing `{}` are
164
+ * the same section: the constructor defaults to an empty record.
165
+ *
166
+ * Attribute names must match `[A-Za-z][A-Za-z0-9_-]*` and values may not
167
+ * contain `"` or a line break; violations fail typed at render, not here, so
168
+ * runtime data can never turn construction into a defect.
169
+ *
146
170
  * @public
147
171
  */
148
172
  declare class Section extends Section_base {
149
173
  /** This section's identity, without its content. */
150
174
  get id(): SectionId;
151
- /** The same section carrying different content. */
175
+ /** The same section carrying different content. Attributes are preserved. */
152
176
  withContent(content: string): Section;
153
177
  }
154
178
  declare const PlacedSection_base: Schema.Class<PlacedSection, Schema.Struct<{
@@ -192,10 +216,16 @@ declare const SectionRenderError_base: Schema.Class<SectionRenderError, Schema.T
192
216
  * declared twice in one call, so the caller stated two intentions for one
193
217
  * block and any choice between them would be a guess; this is the
194
218
  * caller-side twin of the document-side `duplicateSection`.
219
+ * `invalidAttribute` — an attribute's name is outside the
220
+ * `[A-Za-z][A-Za-z0-9_-]*` grammar, or its value contains `"` or a line
221
+ * break; either would render a marker the scanner could not read back
222
+ * verbatim, and there is no escaping mechanism by design.
195
223
  */
196
- readonly reason: Schema.Literals<readonly ["markerInContent", "unknownCommentStyle", "duplicateDeclaration"]>;
224
+ readonly reason: Schema.Literals<readonly ["markerInContent", "unknownCommentStyle", "duplicateDeclaration", "invalidAttribute"]>;
197
225
  /** The key of the section that could not be rendered. */
198
226
  readonly key: Schema.String;
227
+ /** The offending attribute's name, when the refusal names one. */
228
+ readonly attribute: Schema.optionalKey<Schema.String>;
199
229
  }>, import("effect/Cause").YieldableError>;
200
230
  /**
201
231
  * Raised when a section cannot be turned into marker-delimited text.
@@ -0,0 +1,63 @@
1
+ //#region src/internal/attributes.ts
2
+ /** The attribute name grammar. No leading digit, underscore or dash. */
3
+ const ATTRIBUTE_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
4
+ /** True when a value can appear inside an attribute's double quotes verbatim. */
5
+ const isValidAttributeValue = (value) => !value.includes("\"") && !value.includes("\n") && !value.includes("\r");
6
+ const isNameStart = (code) => code >= 65 && code <= 90 || code >= 97 && code <= 122;
7
+ const isNameChar = (code) => isNameStart(code) || code >= 48 && code <= 57 || code === 95 || code === 45;
8
+ const isSeparator = (code) => code === 32 || code === 9;
9
+ /**
10
+ * Parse a captured attribute run, or refuse it.
11
+ *
12
+ * @remarks
13
+ * `undefined` means the run is not a valid attribute run and the line carrying
14
+ * it is **not a marker** — it is ordinary content. That covers a mangled pair
15
+ * and a name declared twice: two values for one name is two intentions for one
16
+ * attribute, and any choice between them would be a guess.
17
+ *
18
+ * Document order is preserved: the returned record's insertion order is the
19
+ * order the pairs appear in the marker.
20
+ *
21
+ * A hand-rolled single pass rather than a validating regex: the run comes off
22
+ * an untrusted document line, and the natural `(pair)(sep pair)*$` pattern
23
+ * backtracks polynomially on hostile near-misses. This walk touches each
24
+ * character exactly once, so a megabyte of adversarial line costs a megabyte
25
+ * of work.
26
+ */
27
+ const parseAttributeRun = (run) => {
28
+ const attributes = {};
29
+ const length = run.length;
30
+ let index = 0;
31
+ let first = true;
32
+ while (index < length) {
33
+ if (!first) {
34
+ if (!isSeparator(run.charCodeAt(index))) return;
35
+ while (index < length && isSeparator(run.charCodeAt(index))) index += 1;
36
+ if (index >= length) return;
37
+ }
38
+ first = false;
39
+ if (!isNameStart(run.charCodeAt(index))) return;
40
+ const nameStart = index;
41
+ index += 1;
42
+ while (index < length && isNameChar(run.charCodeAt(index))) index += 1;
43
+ const name = run.slice(nameStart, index);
44
+ if (index + 1 >= length || run.charCodeAt(index) !== 61 || run.charCodeAt(index + 1) !== 34) return;
45
+ index += 2;
46
+ const valueStart = index;
47
+ while (index < length) {
48
+ const code = run.charCodeAt(index);
49
+ if (code === 34) break;
50
+ if (code === 10 || code === 13) return;
51
+ index += 1;
52
+ }
53
+ if (index >= length) return;
54
+ const value = run.slice(valueStart, index);
55
+ index += 1;
56
+ if (Object.hasOwn(attributes, name)) return;
57
+ attributes[name] = value;
58
+ }
59
+ return first ? void 0 : attributes;
60
+ };
61
+
62
+ //#endregion
63
+ export { ATTRIBUTE_NAME_PATTERN, isValidAttributeValue, parseAttributeRun };
package/internal/scan.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { parseAttributeRun } from "./attributes.js";
1
2
  import { PlacedSection, Section } from "../Section.js";
2
3
 
3
4
  //#region src/internal/scan.ts
@@ -49,13 +50,21 @@ const collectHits = (text, dialect) => {
49
50
  for (const matcher of dialect.matchers()) for (const match of text.matchAll(matcher.regex)) {
50
51
  const start = match.index;
51
52
  if (start === void 0 || seen.has(start)) continue;
53
+ const run = match[3];
54
+ let attributes;
55
+ if (run !== void 0) {
56
+ if (match[1] === "END") continue;
57
+ attributes = parseAttributeRun(run);
58
+ if (attributes === void 0) continue;
59
+ }
52
60
  seen.add(start);
53
61
  hits.push({
54
62
  kind: match[1] === "BEGIN" ? "BEGIN" : "END",
55
63
  key: match[2] ?? "",
56
64
  style: matcher.style,
57
65
  start,
58
- end: start + match[0].length
66
+ end: start + match[0].length,
67
+ ...attributes === void 0 ? {} : { attributes }
59
68
  });
60
69
  }
61
70
  return hits.sort((left, right) => left.start - right.start);
@@ -109,7 +118,8 @@ const scan = (text, dialect) => {
109
118
  section: Section.make({
110
119
  key: open.key,
111
120
  commentStyle: open.style,
112
- content: normalizeEol(inner)
121
+ content: normalizeEol(inner),
122
+ ...open.attributes === void 0 ? {} : { attributes: open.attributes }
113
123
  }),
114
124
  start: open.start,
115
125
  end: hit.end,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/templates",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "Managed-section blocks in user-editable files: parse, reconcile, sync and check delimited regions.",
6
6
  "keywords": [