@flow-as-code/tf 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,88 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ // Rendering FlowDoc content into a `templatefile` template (.tftpl).
6
+ //
7
+ // The body of a .tftpl is Connect Flow language JSON, and that JSON legitimately
8
+ // contains prompt text a caller wrote, which may include the two sequences HCL
9
+ // treats as template introducers: `${` (interpolation) and `%{` (directive).
10
+ // Both must survive rendering byte for byte, so both are escaped by doubling
11
+ // their leading character, per the HCL string template grammar:
12
+ // https://developer.hashicorp.com/terraform/language/expressions/strings#escape-sequences
13
+ // https://github.com/hashicorp/hcl/blob/main/hclsyntax/spec.md#template-expressions
14
+ //
15
+ // Escaping is applied to the WHOLE serialized document, then the reference
16
+ // interpolations this emitter owns are substituted in. Doing it the other way
17
+ // round would escape our own interpolations. The intermediate values are unique
18
+ // sentinels chosen so they cannot appear in the document (see sentinelPrefix),
19
+ // which keeps the substitution from colliding with authored text that happens to
20
+ // look like a sentinel.
21
+
22
+ import { type FlowDoc, materializeWithMap, serializeContent } from "@flow-as-code/core";
23
+
24
+ /**
25
+ * Doubles the leading character of every HCL template introducer so the text
26
+ * renders literally. `${` becomes `$${` and `%{` becomes `%%{`.
27
+ *
28
+ * The two patterns are disjoint and the expansion of one can never produce the
29
+ * other, so a single pass in either order is correct. It is also correct for
30
+ * already-doubled input: HCL scans left to right and takes the longest match,
31
+ * so escaping `$${` to `$$${` renders back to `$${` (a literal `$` followed by
32
+ * the escape for a literal `${`).
33
+ */
34
+ export function escapeTemplateText(text: string): string {
35
+ // The replacements are functions on purpose. A string replacement gives `$`
36
+ // its substitution meaning, so the literal "$${" would be inserted as "${"
37
+ // and every escape would silently undo itself.
38
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replaceAll#specifying_a_string_as_the_replacement
39
+ return text.replaceAll("${", () => "$${").replaceAll("%{", () => "%%{");
40
+ }
41
+
42
+ /** Root of the sentinel names used while substituting reference tokens. */
43
+ const SENTINEL_BASE = "CDREF_TF_SENTINEL";
44
+
45
+ /**
46
+ * A sentinel prefix that does not occur in `haystack`. Authored text is allowed
47
+ * to contain anything, sentinel lookalikes included, so the prefix grows until
48
+ * it is absent from the document being rendered. It terminates: every extra
49
+ * character makes the prefix longer, and a string cannot contain a substring
50
+ * longer than itself.
51
+ */
52
+ export function sentinelPrefix(haystack: string): string {
53
+ let prefix = SENTINEL_BASE;
54
+ while (haystack.includes(prefix)) prefix += "X";
55
+ return `${prefix}_`;
56
+ }
57
+
58
+ /**
59
+ * The `.tftpl` body for one document: deployable content JSON with every
60
+ * `${cdref:...}` token replaced by a `${var}` interpolation naming the entry in
61
+ * `local.flow_refs`, and every other template introducer escaped.
62
+ *
63
+ * `variableNames` maps token to template variable name and must cover every
64
+ * token in the document; @flow-as-code/core's strict materializer raises a
65
+ * MaterializeError listing all missing tokens at once if it does not.
66
+ */
67
+ export function renderTemplate(doc: FlowDoc, variableNames: ReadonlyMap<string, string>): string {
68
+ // Sentinels are chosen against the authored document, before materialization,
69
+ // so a value that looks like a sentinel cannot be smuggled in through a token.
70
+ const prefix = sentinelPrefix(JSON.stringify(doc.content));
71
+
72
+ const sentinels = new Map<string, string>();
73
+ const substitutions = new Map<string, string>();
74
+ let index = 0;
75
+ for (const [token, variable] of [...variableNames].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
76
+ const sentinel = `${prefix}${String(index)}_`;
77
+ index += 1;
78
+ sentinels.set(token, sentinel);
79
+ substitutions.set(sentinel, `\${${variable}}`);
80
+ }
81
+
82
+ const content = materializeWithMap(doc, Object.fromEntries(sentinels));
83
+ let body = escapeTemplateText(serializeContent(content));
84
+ for (const [sentinel, interpolation] of substitutions) {
85
+ body = body.replaceAll(sentinel, interpolation);
86
+ }
87
+ return body;
88
+ }
package/src/write.ts ADDED
@@ -0,0 +1,33 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ // The filesystem half of the emitter, kept apart from emit.ts so the emitter
6
+ // itself stays pure and the goldens compare bytes with no disk involved.
7
+
8
+ import { mkdirSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import type { FlowDoc } from "@flow-as-code/core";
11
+ import { type EmitTfOptions, type EmitTfResult, emitTf } from "./emit.js";
12
+
13
+ /**
14
+ * Emits `docs` and writes the result under `outDir`, creating directories as
15
+ * needed. Returns the emitted files so a caller can report or re-check them.
16
+ *
17
+ * Existing files are overwritten and nothing else in `outDir` is touched: the
18
+ * user owns their provider, backend, and resource files, and this emitter must
19
+ * never remove them.
20
+ */
21
+ export function writeTf(
22
+ docs: readonly FlowDoc[],
23
+ outDir: string,
24
+ options: EmitTfOptions = {},
25
+ ): EmitTfResult {
26
+ const result = emitTf(docs, options);
27
+ for (const [relative, content] of Object.entries(result.files)) {
28
+ const target = join(outDir, relative);
29
+ mkdirSync(dirname(target), { recursive: true });
30
+ writeFileSync(target, content, "utf8");
31
+ }
32
+ return result;
33
+ }