@rr0/cms 0.3.63 → 0.3.64

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.
@@ -178,6 +178,12 @@ export class CMSGenerator {
178
178
  : new HtAccessToNetlifyConfigReplaceCommand(this.options.siteBaseUrl, netlify.preambleFile)
179
179
  ],
180
180
  roots: [".htaccess"],
181
+ // The preamble is the output's OTHER half, and it is not walked as a root — so without this
182
+ // the incremental rule ("is .htaccess newer than the output?") never sees it change, and an
183
+ // edit to it produces a successful build that deploys the previous output. It happened: two
184
+ // dead redirect rules survived their own removal, and only a reading of the deployed file
185
+ // caught it.
186
+ alsoDerivedFrom: netlify.preambleFile ? [netlify.preambleFile] : [],
181
187
  getOutputPath: (_context) => netlify.outputPath
182
188
  };
183
189
  const contentRoots = this.options.contentRoots;
@@ -8,6 +8,25 @@ export interface FileVisitor {
8
8
  visit(context: HtmlRR0Context, processFile: boolean): Promise<void>;
9
9
  contentStepEnd(): Promise<void>;
10
10
  }
11
+ /**
12
+ * A content config whose output is derived from more than the file being walked.
13
+ *
14
+ * The incremental rule this step inherits is "process the file if it is newer than its own output",
15
+ * which is exact as long as the file IS the only input. It is not always: the Netlify redirects are
16
+ * generated from `.htaccess` AND from a preamble holding everything Apache's directives cannot say
17
+ * (see `CMSGenerator`'s `netlify.preambleFile`). Nothing declared that second half, so editing it
18
+ * alone changed nothing — the output stayed at whatever the last `.htaccess` change had produced,
19
+ * and the build reported success. That is the worst shape a staleness bug can take: silent, and
20
+ * visible only to whoever reads the deployed file.
21
+ *
22
+ * Naming the dependency here rather than always reprocessing keeps the incremental build
23
+ * incremental, and puts the statement where the config that needs it can be read.
24
+ */
25
+ export interface ContentStepDependencies {
26
+ /** Paths, as the working directory sees them, that this config's output also derives from. */
27
+ alsoDerivedFrom?: string[];
28
+ }
29
+ export type RR0ContentStepConfig = ContentStepConfig & ContentStepDependencies;
11
30
  export interface RR0ContentStepOptions {
12
31
  contentConfigs: ContentStepConfig[];
13
32
  outputFunc: OutputFunc;
@@ -24,5 +43,15 @@ export declare class RR0ContentStep extends ContentStep<HtmlRR0Context> {
24
43
  protected processFile(context: HtmlRR0Context, filePath: string, contentsConfig: ContentStepConfig): Promise<string | undefined>;
25
44
  protected shouldProcessFile(context: HtmlRR0Context, contentsConfig: ContentStepConfig): Promise<boolean>;
26
45
  protected shouldProcessContent(context: HtmlRR0Context, contentsConfig: ContentStepConfig): Promise<boolean>;
46
+ /**
47
+ * Whether a file this config's output ALSO derives from is newer than that output — see
48
+ * `ContentStepDependencies`.
49
+ *
50
+ * A dependency that has gone missing counts as changed rather than as unchanged: an output built
51
+ * from something no longer there should be rebuilt, and the rebuild is where that failure gets
52
+ * reported (the preamble reader throws on a name it cannot find, deliberately). Answering "no"
53
+ * would keep serving the stale output and say nothing.
54
+ */
55
+ private otherInputHasChanged;
27
56
  protected postExecute(result: ContentStepResult): Promise<ContentStepResult>;
28
57
  }
@@ -1,3 +1,4 @@
1
+ import fs from "fs";
1
2
  import { ContentStep } from "ssg-api";
2
3
  export class RR0ContentStep extends ContentStep {
3
4
  constructor(options, timeService) {
@@ -14,7 +15,8 @@ export class RR0ContentStep extends ContentStep {
14
15
  async shouldProcessFile(context, contentsConfig) {
15
16
  const fileHasChanged = await super.shouldProcessFile(context, contentsConfig);
16
17
  const fileIsForced = this.options.toProcess.has(context.file.name);
17
- const processFile = this.options.force || fileIsForced || fileHasChanged;
18
+ const processFile = this.options.force || fileIsForced || fileHasChanged
19
+ || this.otherInputHasChanged(context, contentsConfig);
18
20
  if (processFile) {
19
21
  this.options.toProcess.add(context.file.name);
20
22
  }
@@ -34,6 +36,27 @@ export class RR0ContentStep extends ContentStep {
34
36
  }
35
37
  return should;
36
38
  }
39
+ /**
40
+ * Whether a file this config's output ALSO derives from is newer than that output — see
41
+ * `ContentStepDependencies`.
42
+ *
43
+ * A dependency that has gone missing counts as changed rather than as unchanged: an output built
44
+ * from something no longer there should be rebuilt, and the rebuild is where that failure gets
45
+ * reported (the preamble reader throws on a name it cannot find, deliberately). Answering "no"
46
+ * would keep serving the stale output and say nothing.
47
+ */
48
+ otherInputHasChanged(context, contentsConfig) {
49
+ const dependencies = contentsConfig.alsoDerivedFrom;
50
+ if (!(dependencies === null || dependencies === void 0 ? void 0 : dependencies.length)) {
51
+ return false;
52
+ }
53
+ const outputPath = contentsConfig.getOutputPath(context);
54
+ if (!fs.existsSync(outputPath)) {
55
+ return true;
56
+ }
57
+ const outputTime = fs.statSync(outputPath).mtime.getTime();
58
+ return dependencies.some(dependency => !fs.existsSync(dependency) || fs.statSync(dependency).mtime.getTime() > outputTime);
59
+ }
37
60
  async postExecute(result) {
38
61
  await super.postExecute(result);
39
62
  for (const fileVisitor of this.options.fileVisitors) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,78 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
5
+ import { RR0ContentStep } from "./RR0ContentStep.js";
6
+ /**
7
+ * The incremental rule, and the second input it used not to know about.
8
+ *
9
+ * Reaches `shouldProcessFile` directly rather than running a whole generation: what is under test
10
+ * is one decision — "is this output stale?" — and the file dates that answer it.
11
+ */
12
+ describe("RR0ContentStep incremental rule", () => {
13
+ let dir;
14
+ let source;
15
+ let preamble;
16
+ let output;
17
+ const at = (file, minutesAgo) => {
18
+ const when = new Date(Date.now() - minutesAgo * 60000);
19
+ fs.utimesSync(file, when, when);
20
+ };
21
+ const contextFor = (file) => ({
22
+ file: { name: file, lastModified: fs.statSync(file).mtime }
23
+ });
24
+ const stepFor = (config) => {
25
+ const step = new RR0ContentStep({
26
+ contentConfigs: [config], outputFunc: async () => undefined, force: false,
27
+ name: "test", toProcess: new Set()
28
+ }, { setContextFromFile: () => undefined });
29
+ return step;
30
+ };
31
+ const shouldProcess = (step, config) => step.shouldProcessFile(contextFor(source), config);
32
+ const configWith = (alsoDerivedFrom) => ({
33
+ roots: [source], replacements: [], alsoDerivedFrom, getOutputPath: (_context) => output
34
+ });
35
+ beforeEach(() => {
36
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "rr0-content-step-"));
37
+ source = path.join(dir, ".htaccess");
38
+ preamble = path.join(dir, "_redirects.head");
39
+ output = path.join(dir, "_redirects");
40
+ fs.writeFileSync(source, "Redirect /a /b\n");
41
+ fs.writeFileSync(preamble, "# trunk\n");
42
+ fs.writeFileSync(output, "# trunk\n/a /b\n");
43
+ // An output built after both its inputs: nothing to do.
44
+ at(source, 10);
45
+ at(preamble, 10);
46
+ at(output, 5);
47
+ });
48
+ afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
49
+ test("leaves an output alone while neither of its inputs has moved", async () => {
50
+ const config = configWith([preamble]);
51
+ expect(await shouldProcess(stepFor(config), config)).toBe(false);
52
+ });
53
+ test("rebuilds when the walked file changes, as it always did", async () => {
54
+ at(source, 0);
55
+ const config = configWith([preamble]);
56
+ expect(await shouldProcess(stepFor(config), config)).toBe(true);
57
+ });
58
+ test("rebuilds when only the PREAMBLE changes — the bug this exists for", async () => {
59
+ at(preamble, 0);
60
+ const config = configWith([preamble]);
61
+ expect(await shouldProcess(stepFor(config), config)).toBe(true);
62
+ });
63
+ test("without the declaration, a preamble change goes on being missed", async () => {
64
+ at(preamble, 0);
65
+ const config = configWith([]);
66
+ expect(await shouldProcess(stepFor(config), config)).toBe(false);
67
+ });
68
+ test("a dependency that has gone missing counts as changed, not as unchanged", async () => {
69
+ fs.rmSync(preamble);
70
+ const config = configWith([preamble]);
71
+ expect(await shouldProcess(stepFor(config), config)).toBe(true);
72
+ });
73
+ test("no output yet means build it, whatever the dates say", async () => {
74
+ fs.rmSync(output);
75
+ const config = configWith([preamble]);
76
+ expect(await shouldProcess(stepFor(config), config)).toBe(true);
77
+ });
78
+ });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@rr0/cms",
3
3
  "type": "module",
4
4
  "author": "Jérôme Beau <rr0@rr0.org> (https://rr0.org)",
5
- "version": "0.3.63",
5
+ "version": "0.3.64",
6
6
  "description": "RR0 Content Management System (CMS)",
7
7
  "exports": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",