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 +28 -0
- package/dist/lib/_internal/compiler/compiler.d.ts +6 -0
- package/dist/lib/_internal/compiler/compiler.d.ts.map +1 -1
- package/dist/lib/_internal/compiler/compiler.js +60 -3
- package/dist/lib/_internal/compiler/header-vars.d.ts +41 -0
- package/dist/lib/_internal/compiler/header-vars.d.ts.map +1 -0
- package/dist/lib/_internal/compiler/header-vars.js +117 -0
- package/dist/lib/_internal/generators/support/header.d.ts.map +1 -1
- package/dist/lib/_internal/generators/support/header.js +2 -0
- package/package.json +1 -1
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;
|
|
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
|
|
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,
|
|
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. */
|