firebase-contract 0.1.0-rc.7 → 0.1.0-rc.8

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
@@ -132,6 +132,34 @@ fbc generate --header "Managed by fbc" # custom text (multi-line ok)
132
132
  header: default # or any text; the CLI --header flag wins
133
133
  ```
134
134
 
135
+ Header text (contract `header:`, per-generator `header`, CLI `--header`)
136
+ supports `${...}` template variables:
137
+
138
+ | Variable | Value |
139
+ | --- | --- |
140
+ | `${rootContractPath}` | entry (root) yml path, relative to the root yml's directory |
141
+ | `${currentContractPath}` | the yml the output is generated from |
142
+ | `${allContractPath}` | import chain root → current, joined with ` -> ` |
143
+ | `${generatedAt}` | date the file was **first** generated (carried over on regenerate) |
144
+ | `${updatedAt}` | date the content **last changed** |
145
+
146
+ A regenerate that produces identical content keeps both dates and reproduces
147
+ the file byte-for-byte, so `--check` stays drift-free; when content changes,
148
+ `generatedAt` is carried over from the existing file and only `updatedAt`
149
+ moves. Date tokens accept an optional format — `${updatedAt:yyyy-mm-dd HH:mm:ss}`
150
+ — with runs `yyyy`/`yy`, `MM`, `dd`, `HH`, `ss`, and `mm` meaning month before
151
+ any hour token / minute after one (both `yyyy-mm-dd` and `HH:mm` read
152
+ naturally). Default: `yyyy-MM-dd`.
153
+
154
+ The `default` banner includes the source and dates:
155
+
156
+ ```
157
+ // AUTO-GENERATED by firebase-contract. Do not edit by hand.
158
+ // Source: ${currentContractPath}
159
+ // Generated: ${generatedAt} / Updated: ${updatedAt}
160
+ // Regenerate with: fbc generate
161
+ ```
162
+
135
163
  Comment syntax adapts per file type (`//` for TS, `#` for GraphQL/YAML-ish,
136
164
  `--` for SQL).
137
165
 
@@ -4,6 +4,7 @@ import { RawContract } from '../parser/raw-document.js';
4
4
  import { ModuleLoader } from '../resolver/module-loader.js';
5
5
  import { GeneratedFile, GeneratorContext } from '../generators/generator.js';
6
6
  import { GeneratorRegistry } from '../generators/registry.js';
7
+ import { HeaderPathVars } from './header-vars.js';
7
8
  export interface CompileOptions {
8
9
  /** Override module loading (defaults to the file system). */
9
10
  loader?: ModuleLoader;
@@ -29,6 +30,10 @@ export interface GenerateOptions extends CompileOptions {
29
30
  /** When true, write the generated files to disk. */
30
31
  write?: boolean;
31
32
  context?: GeneratorContext;
33
+ /** Header `${...}` path values; defaults to entry-only (root = current = entry). */
34
+ headerVars?: HeaderPathVars;
35
+ /** Clock for `${generatedAt}`/`${updatedAt}` stamping (tests); defaults to now. */
36
+ now?: Date;
32
37
  }
33
38
  export interface GenerateTargetResult {
34
39
  /** The contract file that declared this target. */
@@ -54,6 +59,7 @@ export declare const generateAll: (entryPath: string, options?: {
54
59
  write?: boolean;
55
60
  context?: GeneratorContext;
56
61
  loader?: ModuleLoader;
62
+ now?: Date;
57
63
  }) => GenerateAllResult;
58
64
  export interface FileDrift {
59
65
  /** Files whose on-disk content differs from the generated content. */
@@ -1 +1 @@
1
- {"version":3,"file":"compiler.d.ts","sourceRoot":"","sources":["../../../../src/lib/_internal/compiler/compiler.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,UAAU,EAAoB,MAAM,mBAAmB,CAAA;AAEhE,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAA;AAChC,OAAO,EAAU,WAAW,EAAqC,MAAM,2BAA2B,CAAA;AAElG,OAAO,EAAkB,YAAY,EAAE,MAAM,8BAA8B,CAAA;AAG3E,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAA;AAI5E,OAAO,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAA;AAE7D,MAAM,WAAW,cAAc;IAC7B,6DAA6D;IAC7D,MAAM,CAAC,EAAE,YAAY,CAAA;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,EAAE,CAAA;IACN,SAAS,EAAE,WAAW,EAAE,CAAA;IACxB,WAAW,EAAE,UAAU,EAAE,CAAA;CAC1B;AAED;;;;;GAKG;AACH,eAAO,MAAM,OAAO,GAAI,WAAW,MAAM,EAAE,UAAS,cAAmB,KAAG,aAUzE,CAAA;AAED,MAAM,WAAW,eAAgB,SAAQ,cAAc;IACrD,MAAM,EAAE,MAAM,CAAA;IACd,2EAA2E;IAC3E,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,iBAAiB,CAAA;IAC5B,oDAAoD;IACpD,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,OAAO,CAAC,EAAE,gBAAgB,CAAA;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACnC,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,KAAK,EAAE,aAAa,EAAE,CAAA;CACvB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,oBAAoB,EAAE,CAAA;IAC/B,WAAW,EAAE,UAAU,EAAE,CAAA;IACzB,EAAE,EAAE,OAAO,CAAA;CACZ;AAmND;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,GACtB,WAAW,MAAM,EACjB,UAAS;IAAE,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAAC,MAAM,CAAC,EAAE,YAAY,CAAA;CAAO,KACjH,iBAuDF,CAAA;AAED,MAAM,WAAW,SAAS;IACxB,sEAAsE;IACtE,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,mEAAmE;IACnE,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED;;;;GAIG;AACH,eAAO,MAAM,SAAS,GAAI,OAAO,aAAa,EAAE,KAAG,SAUlD,CAAA;AAED,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,EAAE,CAAA;IACN,WAAW,EAAE,UAAU,EAAE,CAAA;IACzB,oFAAoF;IACpF,KAAK,EAAE,aAAa,EAAE,CAAA;IACtB,0DAA0D;IAC1D,EAAE,EAAE,OAAO,CAAA;CACZ;AAED;;;;GAIG;AACH,eAAO,MAAM,QAAQ,GAAI,WAAW,MAAM,EAAE,SAAS,eAAe,KAAG,cAuCtE,CAAA"}
1
+ {"version":3,"file":"compiler.d.ts","sourceRoot":"","sources":["../../../../src/lib/_internal/compiler/compiler.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,UAAU,EAAoB,MAAM,mBAAmB,CAAA;AAEhE,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAA;AAChC,OAAO,EAAU,WAAW,EAAqC,MAAM,2BAA2B,CAAA;AAElG,OAAO,EAAkB,YAAY,EAAE,MAAM,8BAA8B,CAAA;AAG3E,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAA;AAI5E,OAAO,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAA;AAC7D,OAAO,EAEL,cAAc,EAGf,MAAM,kBAAkB,CAAA;AAEzB,MAAM,WAAW,cAAc;IAC7B,6DAA6D;IAC7D,MAAM,CAAC,EAAE,YAAY,CAAA;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,EAAE,CAAA;IACN,SAAS,EAAE,WAAW,EAAE,CAAA;IACxB,WAAW,EAAE,UAAU,EAAE,CAAA;CAC1B;AAED;;;;;GAKG;AACH,eAAO,MAAM,OAAO,GAAI,WAAW,MAAM,EAAE,UAAS,cAAmB,KAAG,aAUzE,CAAA;AAED,MAAM,WAAW,eAAgB,SAAQ,cAAc;IACrD,MAAM,EAAE,MAAM,CAAA;IACd,2EAA2E;IAC3E,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,iBAAiB,CAAA;IAC5B,oDAAoD;IACpD,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,OAAO,CAAC,EAAE,gBAAgB,CAAA;IAC1B,oFAAoF;IACpF,UAAU,CAAC,EAAE,cAAc,CAAA;IAC3B,mFAAmF;IACnF,GAAG,CAAC,EAAE,IAAI,CAAA;CACX;AAED,MAAM,WAAW,oBAAoB;IACnC,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,KAAK,EAAE,aAAa,EAAE,CAAA;CACvB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,oBAAoB,EAAE,CAAA;IAC/B,WAAW,EAAE,UAAU,EAAE,CAAA;IACzB,EAAE,EAAE,OAAO,CAAA;CACZ;AAmND;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,GACtB,WAAW,MAAM,EACjB,UAAS;IACP,QAAQ,CAAC,EAAE,iBAAiB,CAAA;IAC5B,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,OAAO,CAAC,EAAE,gBAAgB,CAAA;IAC1B,MAAM,CAAC,EAAE,YAAY,CAAA;IACrB,GAAG,CAAC,EAAE,IAAI,CAAA;CACN,KACL,iBA0FF,CAAA;AAED,MAAM,WAAW,SAAS;IACxB,sEAAsE;IACtE,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,mEAAmE;IACnE,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED;;;;GAIG;AACH,eAAO,MAAM,SAAS,GAAI,OAAO,aAAa,EAAE,KAAG,SAUlD,CAAA;AAED,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,EAAE,CAAA;IACN,WAAW,EAAE,UAAU,EAAE,CAAA;IACzB,oFAAoF;IACpF,KAAK,EAAE,aAAa,EAAE,CAAA;IACtB,0DAA0D;IAC1D,EAAE,EAAE,OAAO,CAAA;CACZ;AAED;;;;GAIG;AACH,eAAO,MAAM,QAAQ,GAAI,WAAW,MAAM,EAAE,SAAS,eAAe,KAAG,cAgEtE,CAAA"}
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
2
+ import { dirname, join, relative } from 'node:path';
3
3
  import { error, hasErrors } from '../diagnostics.js';
4
4
  import { buildIr } from '../ir/build-ir.js';
5
5
  import { resolveImports } from '../resolver/import-resolver.js';
@@ -8,6 +8,7 @@ import { validateIr } from '../validation/validate.js';
8
8
  import { createDefaultRegistry } from '../generators/index.js';
9
9
  import { toHeaderComment } from '../generators/support/header.js';
10
10
  import { API_PLACEHOLDER_RE, expandApiPlaceholders } from '../generators/support/templates.js';
11
+ import { expandHeaderPathVars, hasDateTokens, resolveDateTokens, } from './header-vars.js';
11
12
  /**
12
13
  * Run the front end of the pipeline: resolve imports, build the IR, and run
13
14
  * semantic validation. Generators are intentionally not involved here — the CLI
@@ -206,6 +207,42 @@ export const generateAll = (entryPath, options = {}) => {
206
207
  const registry = options.registry ?? createDefaultRegistry();
207
208
  // Import order puts dependencies first, so the entry (root) document is last.
208
209
  const rootDoc = documents[documents.length - 1];
210
+ const loader = options.loader ?? createFsLoader();
211
+ const rootDir = dirname(rootDoc.filePath);
212
+ const relToRoot = (path) => relative(rootDir, path);
213
+ /** Shortest import chain root → target (BFS over resolved import edges). */
214
+ const importChain = (targetDoc) => {
215
+ if (targetDoc.filePath === rootDoc.filePath)
216
+ return [rootDoc.filePath];
217
+ const byPath = new Map(documents.map(document => [document.filePath, document]));
218
+ const queue = [[rootDoc.filePath]];
219
+ const seen = new Set([rootDoc.filePath]);
220
+ while (queue.length > 0) {
221
+ const chain = queue.shift();
222
+ const doc = byPath.get(chain[chain.length - 1]);
223
+ for (const specifier of doc?.imports ?? []) {
224
+ let importId;
225
+ try {
226
+ importId = loader.resolveId(specifier, doc?.filePath ?? '');
227
+ }
228
+ catch {
229
+ continue;
230
+ }
231
+ if (importId === targetDoc.filePath)
232
+ return [...chain, importId];
233
+ if (!seen.has(importId) && byPath.has(importId)) {
234
+ seen.add(importId);
235
+ queue.push([...chain, importId]);
236
+ }
237
+ }
238
+ }
239
+ return [rootDoc.filePath, targetDoc.filePath];
240
+ };
241
+ const headerVarsFor = (doc) => ({
242
+ rootContractPath: relToRoot(rootDoc.filePath),
243
+ currentContractPath: relToRoot(doc.filePath),
244
+ allContractPath: importChain(doc).map(relToRoot).join(' -> '),
245
+ });
209
246
  const mergeDiagnostics = (incoming) => {
210
247
  // Per-target compiles revisit shared imports; keep only new diagnostics.
211
248
  for (const diagnostic of incoming) {
@@ -227,6 +264,8 @@ export const generateAll = (entryPath, options = {}) => {
227
264
  // Per-generator header beats the CLI/contract-level one ('' disables it).
228
265
  ...(header !== undefined ? { header: toHeaderComment(header) } : {}),
229
266
  },
267
+ headerVars: headerVarsFor(doc),
268
+ ...(options.now ? { now: options.now } : {}),
230
269
  ...(options.loader ? { loader: options.loader } : {}),
231
270
  });
232
271
  mergeDiagnostics(result.diagnostics);
@@ -281,6 +320,15 @@ export const generate = (entryPath, options) => {
281
320
  if (context.header === undefined && ir.header !== undefined) {
282
321
  context.header = toHeaderComment(ir.header);
283
322
  }
323
+ if (context.header !== undefined) {
324
+ const entryName = relative(dirname(entryPath), entryPath);
325
+ const vars = options.headerVars ?? {
326
+ rootContractPath: entryName,
327
+ currentContractPath: entryName,
328
+ allContractPath: entryName,
329
+ };
330
+ context.header = expandHeaderPathVars(context.header, vars);
331
+ }
284
332
  const files = [];
285
333
  for (const name of names) {
286
334
  const generator = registry.get(name);
@@ -295,11 +343,20 @@ export const generate = (entryPath, options) => {
295
343
  if (hasErrors(collected)) {
296
344
  return { ir, diagnostics: collected, files: [], ok: false };
297
345
  }
346
+ // `${generatedAt}`/`${updatedAt}` resolve against what is on disk so an
347
+ // unchanged regenerate reproduces the file byte-for-byte (keeps --check green).
348
+ const now = options.now ?? new Date();
349
+ const resolved = files.map(file => hasDateTokens(file.content)
350
+ ? {
351
+ ...file,
352
+ content: resolveDateTokens(file.content, existsSync(file.path) ? readFileSync(file.path, 'utf8') : undefined, now),
353
+ }
354
+ : file);
298
355
  if (options.write) {
299
- for (const file of files) {
356
+ for (const file of resolved) {
300
357
  mkdirSync(dirname(file.path), { recursive: true });
301
358
  writeFileSync(file.path, file.content, 'utf8');
302
359
  }
303
360
  }
304
- return { ir, diagnostics: collected, files, ok: true };
361
+ return { ir, diagnostics: collected, files: resolved, ok: true };
305
362
  };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `${...}` template variables available in header text (contract `header:`,
3
+ * per-generator `header`, CLI `--header`):
4
+ *
5
+ * - `${rootContractPath}` — entry (root) yml, relative to the root yml's dir
6
+ * - `${currentContractPath}` — the yml the output is generated from
7
+ * - `${allContractPath}` — import chain root → current, joined with ` -> `
8
+ * - `${generatedAt}` — date the file was first generated (kept stable)
9
+ * - `${updatedAt}` — date the content last changed (a regenerate that
10
+ * produces identical content keeps both dates, so
11
+ * `--check` stays drift-free)
12
+ *
13
+ * Date tokens accept an optional format: `${updatedAt:yyyy-mm-dd HH:mm:ss}`.
14
+ * Format runs: `yyyy`/`yy` year, `MM` month, `dd` day, `HH` hour, `mm` month
15
+ * before any hour token / minute after one (so both `yyyy-mm-dd` and `HH:mm`
16
+ * read naturally), `ss` second. The default format is `yyyy-MM-dd`.
17
+ *
18
+ * Path variables are expanded before generators run; date variables survive
19
+ * into the rendered content and are resolved per file against what is already
20
+ * on disk (see {@link resolveDateTokens}).
21
+ */
22
+ export interface HeaderPathVars {
23
+ rootContractPath: string;
24
+ currentContractPath: string;
25
+ allContractPath: string;
26
+ }
27
+ export declare const expandHeaderPathVars: (header: string, vars: HeaderPathVars) => string;
28
+ export declare const DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
29
+ export declare const hasDateTokens: (content: string) => boolean;
30
+ /** Render `now` with a format string (see the module doc for the token set). */
31
+ export declare const formatDate: (format: string, now: Date) => string;
32
+ /**
33
+ * Resolve `${generatedAt}` / `${updatedAt}` in freshly rendered content against
34
+ * the previously written file:
35
+ *
36
+ * - unchanged content (modulo the dates themselves) → both dates carried over
37
+ * - changed or new content → `updatedAt` = now; `generatedAt` recovered from
38
+ * the existing file's matching header line when possible, else now
39
+ */
40
+ export declare const resolveDateTokens: (content: string, existing: string | undefined, now: Date) => string;
41
+ //# sourceMappingURL=header-vars.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"header-vars.d.ts","sourceRoot":"","sources":["../../../../src/lib/_internal/compiler/header-vars.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,WAAW,cAAc;IAC7B,gBAAgB,EAAE,MAAM,CAAA;IACxB,mBAAmB,EAAE,MAAM,CAAA;IAC3B,eAAe,EAAE,MAAM,CAAA;CACxB;AAED,eAAO,MAAM,oBAAoB,GAAI,QAAQ,MAAM,EAAE,MAAM,cAAc,KAAG,MAIjB,CAAA;AAE3D,eAAO,MAAM,mBAAmB,eAAe,CAAA;AAM/C,eAAO,MAAM,aAAa,GAAI,SAAS,MAAM,KAAG,OAAsC,CAAA;AAMtF,gFAAgF;AAChF,eAAO,MAAM,UAAU,GAAI,QAAQ,MAAM,EAAE,KAAK,IAAI,KAAG,MAwBtD,CAAA;AAyBD;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,GAAI,SAAS,MAAM,EAAE,UAAU,MAAM,GAAG,SAAS,EAAE,KAAK,IAAI,KAAG,MA4B5F,CAAA"}
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `${...}` template variables available in header text (contract `header:`,
3
+ * per-generator `header`, CLI `--header`):
4
+ *
5
+ * - `${rootContractPath}` — entry (root) yml, relative to the root yml's dir
6
+ * - `${currentContractPath}` — the yml the output is generated from
7
+ * - `${allContractPath}` — import chain root → current, joined with ` -> `
8
+ * - `${generatedAt}` — date the file was first generated (kept stable)
9
+ * - `${updatedAt}` — date the content last changed (a regenerate that
10
+ * produces identical content keeps both dates, so
11
+ * `--check` stays drift-free)
12
+ *
13
+ * Date tokens accept an optional format: `${updatedAt:yyyy-mm-dd HH:mm:ss}`.
14
+ * Format runs: `yyyy`/`yy` year, `MM` month, `dd` day, `HH` hour, `mm` month
15
+ * before any hour token / minute after one (so both `yyyy-mm-dd` and `HH:mm`
16
+ * read naturally), `ss` second. The default format is `yyyy-MM-dd`.
17
+ *
18
+ * Path variables are expanded before generators run; date variables survive
19
+ * into the rendered content and are resolved per file against what is already
20
+ * on disk (see {@link resolveDateTokens}).
21
+ */
22
+ export const expandHeaderPathVars = (header, vars) => header
23
+ .replaceAll('${rootContractPath}', vars.rootContractPath)
24
+ .replaceAll('${currentContractPath}', vars.currentContractPath)
25
+ .replaceAll('${allContractPath}', vars.allContractPath);
26
+ export const DEFAULT_DATE_FORMAT = 'yyyy-MM-dd';
27
+ const DATE_TOKEN_RE = /\$\{(generatedAt|updatedAt)(?::([^}]+))?\}/;
28
+ const DATE_TOKEN_ALL_RE = /\$\{(generatedAt|updatedAt)(?::([^}]+))?\}/g;
29
+ const FORMAT_RUN_RE = /y+|[Mm]+|[Dd]+|[Hh]+|s+/g;
30
+ export const hasDateTokens = (content) => DATE_TOKEN_RE.test(content);
31
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
+ const pad = (value, width) => String(value).padStart(width, '0');
33
+ /** Render `now` with a format string (see the module doc for the token set). */
34
+ export const formatDate = (format, now) => {
35
+ let hourSeen = false;
36
+ let monthDone = false;
37
+ return format.replace(FORMAT_RUN_RE, run => {
38
+ const char = run[0];
39
+ const width = run.length;
40
+ if (char === 'y') {
41
+ return width <= 2 ? String(now.getFullYear()).slice(-2) : pad(now.getFullYear(), width);
42
+ }
43
+ if (char === 'M' || char === 'm') {
44
+ const isMonth = char === 'M' || (!monthDone && !hourSeen);
45
+ if (isMonth) {
46
+ monthDone = true;
47
+ return pad(now.getMonth() + 1, width);
48
+ }
49
+ return pad(now.getMinutes(), width);
50
+ }
51
+ if (char === 'D' || char === 'd')
52
+ return pad(now.getDate(), width);
53
+ if (char === 'H' || char === 'h') {
54
+ hourSeen = true;
55
+ return pad(now.getHours(), width);
56
+ }
57
+ return pad(now.getSeconds(), width);
58
+ });
59
+ };
60
+ /** Regex source matching any value rendered with `format` (digits per run, rest literal). */
61
+ const formatToPattern = (format) => {
62
+ let out = '';
63
+ let last = 0;
64
+ for (const match of format.matchAll(FORMAT_RUN_RE)) {
65
+ out += escapeRegExp(format.slice(last, match.index)) + String.raw `\d{${match[0].length}}`;
66
+ last = match.index + match[0].length;
67
+ }
68
+ return out + escapeRegExp(format.slice(last));
69
+ };
70
+ /** Escaped-template → regex source with one capture per date token; records token order. */
71
+ const toDatePattern = (template, tokens) => {
72
+ let out = '';
73
+ let last = 0;
74
+ for (const match of template.matchAll(DATE_TOKEN_ALL_RE)) {
75
+ tokens.push(match[1]);
76
+ out += escapeRegExp(template.slice(last, match.index)) + `(${formatToPattern(match[2] ?? DEFAULT_DATE_FORMAT)})`;
77
+ last = match.index + match[0].length;
78
+ }
79
+ return out + escapeRegExp(template.slice(last));
80
+ };
81
+ /**
82
+ * Resolve `${generatedAt}` / `${updatedAt}` in freshly rendered content against
83
+ * the previously written file:
84
+ *
85
+ * - unchanged content (modulo the dates themselves) → both dates carried over
86
+ * - changed or new content → `updatedAt` = now; `generatedAt` recovered from
87
+ * the existing file's matching header line when possible, else now
88
+ */
89
+ export const resolveDateTokens = (content, existing, now) => {
90
+ if (!hasDateTokens(content))
91
+ return content;
92
+ if (existing !== undefined) {
93
+ const tokens = [];
94
+ const full = new RegExp(`^${toDatePattern(content, tokens)}$`);
95
+ const match = existing.match(full);
96
+ if (match) {
97
+ let index = 0;
98
+ return content.replace(DATE_TOKEN_ALL_RE, () => match[++index]);
99
+ }
100
+ }
101
+ let generatedAt;
102
+ if (existing !== undefined) {
103
+ const generatedLine = content.split('\n').find(line => /\$\{generatedAt(?::[^}]+)?\}/.test(line));
104
+ if (generatedLine !== undefined) {
105
+ const lineTokens = [];
106
+ const lineMatch = existing.match(new RegExp(toDatePattern(generatedLine, lineTokens)));
107
+ if (lineMatch) {
108
+ generatedAt = lineMatch[lineTokens.indexOf('generatedAt') + 1];
109
+ }
110
+ }
111
+ }
112
+ return content.replace(DATE_TOKEN_ALL_RE, (_, name, format) => {
113
+ if (name === 'generatedAt' && generatedAt !== undefined)
114
+ return generatedAt;
115
+ return formatDate(format ?? DEFAULT_DATE_FORMAT, now);
116
+ });
117
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"header.d.ts","sourceRoot":"","sources":["../../../../../src/lib/_internal/generators/support/header.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AAElD,gEAAgE;AAChE,eAAO,MAAM,cAAc,QAGf,CAAA;AAEZ,2FAA2F;AAC3F,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,KAAG,MACwD,CAAA;AAEvG,mFAAmF;AACnF,eAAO,MAAM,SAAS,GAAI,SAAS,gBAAgB,GAAG,SAAS,KAAG,MAA+B,CAAA;AAEjG;;;GAGG;AACH,eAAO,MAAM,YAAY,GACvB,SAAS,gBAAgB,GAAG,SAAS,EACrC,YAAW,CAAC,MAAM,EAAE,MAAM,KAAK,MAAyB,KACvD,MAAM,EAGR,CAAA"}
1
+ {"version":3,"file":"header.d.ts","sourceRoot":"","sources":["../../../../../src/lib/_internal/generators/support/header.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AAElD,gEAAgE;AAChE,eAAO,MAAM,cAAc,QAKf,CAAA;AAEZ,2FAA2F;AAC3F,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,KAAG,MACwD,CAAA;AAEvG,mFAAmF;AACnF,eAAO,MAAM,SAAS,GAAI,SAAS,gBAAgB,GAAG,SAAS,KAAG,MAA+B,CAAA;AAEjG;;;GAGG;AACH,eAAO,MAAM,YAAY,GACvB,SAAS,gBAAgB,GAAG,SAAS,EACrC,YAAW,CAAC,MAAM,EAAE,MAAM,KAAK,MAAyB,KACvD,MAAM,EAGR,CAAA"}
@@ -1,6 +1,8 @@
1
1
  /** Default banner marking a file as generated (do-not-edit). */
2
2
  export const DEFAULT_HEADER = [
3
3
  '// AUTO-GENERATED by firebase-contract. Do not edit by hand.',
4
+ '// Source: ${currentContractPath}',
5
+ '// Generated: ${generatedAt} / Updated: ${updatedAt}',
4
6
  '// Regenerate with: fbc generate',
5
7
  ].join('\n');
6
8
  /** Turn raw header text into `//` comment lines; `default` selects the built-in banner. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firebase-contract",
3
- "version": "0.1.0-rc.7",
3
+ "version": "0.1.0-rc.8",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "firebase-contract": "./dist/lib/cli/index.js",