@rr0/cms 0.3.66 → 0.3.68

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.
@@ -24,6 +24,15 @@ import { PlaceReplacerFactory } from "./place/PlaceReplacerFactory.js";
24
24
  import { IndexedReplacerFactory } from "./index/IndexedReplacerFactory.js";
25
25
  import { UnitReplaceCommand } from "./value/UnitReplaceCommand.js";
26
26
  import { cmsTestUtil } from "./test/CMSTestUtil.js";
27
+ /**
28
+ * Chronological order: a period's page comes before the pages of its sub-periods ("1960" before "April 1960"),
29
+ * which a plain sort does not do since "04/index.html" < "index.html".
30
+ */
31
+ function periodFirst(a, b) {
32
+ const dirA = a.substring(0, a.lastIndexOf("/") + 1);
33
+ const dirB = b.substring(0, b.lastIndexOf("/") + 1);
34
+ return dirA < dirB ? -1 : dirA > dirB ? 1 : 0;
35
+ }
27
36
  export async function getTimeFiles() {
28
37
  const minusYearFiles = await glob(cmsTestUtil.filePath("time/-?/?/?/?/index.html"));
29
38
  const year1Files = await glob(cmsTestUtil.filePath("time/?/index.html"));
@@ -32,7 +41,7 @@ export async function getTimeFiles() {
32
41
  const year4Files = await glob(cmsTestUtil.filePath("time/?/?/?/?/index.html"));
33
42
  const monthFiles = await glob(cmsTestUtil.filePath("time/?/?/?/?/??/index.html"));
34
43
  const dayFiles = await glob(cmsTestUtil.filePath("time/?/?/?/?/??/??/index.html"));
35
- return year1Files.concat(year2Files).concat(year3Files).concat(year4Files).concat(minusYearFiles).concat(monthFiles).concat(dayFiles).sort();
44
+ return year1Files.concat(year2Files).concat(year3Files).concat(year4Files).concat(minusYearFiles).concat(monthFiles).concat(dayFiles).sort(periodFirst);
36
45
  }
37
46
  describe("Build", () => {
38
47
  console.time("ssg");
@@ -14,6 +14,15 @@ export declare class DataContentVisitor implements ContentVisitor {
14
14
  visit(context: HtmlRR0Context): Promise<void>;
15
15
  protected process(context: HtmlRR0Context, data: RR0Data): Promise<void>;
16
16
  protected processEvent(context: HtmlRR0Context, event: RR0Event, data: RR0Data): Promise<void>;
17
+ /** Where the UFO@home player that replays a case's sightings is served from. */
18
+ static readonly SIGHTING_PLAYER_SCRIPT = "https://ufoathome.org/lib/rr0-sighting.mjs";
19
+ /**
20
+ * A sighting event of a case points at a witness's recording (its `url`, a UFO@home
21
+ * `sighting.json`): the page shows them in the UFO@home player, reading the case.json itself so
22
+ * that every sighting of the case is offered, not only this one. Inserted at the end of the page's
23
+ * contents, once, and only if the page does not already embed a player where its author put one.
24
+ */
25
+ protected processSighting(context: HtmlRR0Context, data: RR0Data): void;
17
26
  protected timeParagraph(context: HtmlRR0Context, event: RR0Event): {
18
27
  eventP: HTMLParagraphElement;
19
28
  timeEl: HTMLElement;
@@ -46,12 +46,38 @@ export class DataContentVisitor {
46
46
  case "death":
47
47
  await this.processDeath(context, event, data);
48
48
  break;
49
+ case "sighting":
50
+ this.processSighting(context, data);
51
+ break;
49
52
  default:
50
53
  const { eventP, timeEl } = this.timeParagraph(context, event);
51
54
  await this.eventRenderer.render(context, event, eventP);
52
55
  context.file.document.append(eventP);
53
56
  }
54
57
  }
58
+ /**
59
+ * A sighting event of a case points at a witness's recording (its `url`, a UFO@home
60
+ * `sighting.json`): the page shows them in the UFO@home player, reading the case.json itself so
61
+ * that every sighting of the case is offered, not only this one. Inserted at the end of the page's
62
+ * contents, once, and only if the page does not already embed a player where its author put one.
63
+ */
64
+ processSighting(context, data) {
65
+ const doc = context.file.document;
66
+ if (data.type !== "case" || doc.querySelector("rr0-sighting")) {
67
+ return;
68
+ }
69
+ const parentEl = doc.querySelector(".contents") || doc.body;
70
+ if (!parentEl) {
71
+ context.warn("no .contents to insert the sighting player in", context.file.name);
72
+ return;
73
+ }
74
+ const scriptEl = doc.createElement("script");
75
+ scriptEl.type = "module";
76
+ scriptEl.src = DataContentVisitor.SIGHTING_PLAYER_SCRIPT;
77
+ const playerEl = doc.createElement("rr0-sighting");
78
+ playerEl.setAttribute("src", "case.json");
79
+ parentEl.append(scriptEl, playerEl);
80
+ }
55
81
  timeParagraph(context, event) {
56
82
  const container = context.file.document.createElement("p");
57
83
  const eventContext = context.clone();
@@ -155,3 +181,5 @@ export class DataContentVisitor {
155
181
  }
156
182
  }
157
183
  }
184
+ /** Where the UFO@home player that replays a case's sightings is served from. */
185
+ DataContentVisitor.SIGHTING_PLAYER_SCRIPT = "https://ufoathome.org/lib/rr0-sighting.mjs";
@@ -8,6 +8,9 @@ class TestDataContentVisitor extends DataContentVisitor {
8
8
  renderImage(context, event) {
9
9
  return this.processImage(context, event);
10
10
  }
11
+ renderEvent(context, event, data) {
12
+ return this.processEvent(context, event, data);
13
+ }
11
14
  }
12
15
  describe("DataContentVisitor", () => {
13
16
  const portraitEvent = {
@@ -31,4 +34,27 @@ describe("DataContentVisitor", () => {
31
34
  await visitor.renderImage(context, portraitEvent);
32
35
  expect(context.file.document.querySelectorAll(".contents img[src=\"portrait.jpg\"]").length).toBe(1);
33
36
  });
37
+ const sightingEvent = { type: "event", eventType: "sighting", url: "sighting.json" };
38
+ const aCase = { type: "case", events: [sightingEvent] };
39
+ test("inserts the UFO@home player on the case, for a case with a sighting and no player", async () => {
40
+ const context = cmsTestUtil.newHtmlContext("people/v/VertongenJeanLuc/index.html", "<div class=\"contents\"><p>Le dossier</p></div>");
41
+ const visitor = new TestDataContentVisitor();
42
+ await visitor.renderEvent(context, sightingEvent, aCase);
43
+ await visitor.renderEvent(context, sightingEvent, aCase);
44
+ const players = context.file.document.querySelectorAll(".contents > rr0-sighting");
45
+ expect(players.length).toBe(1);
46
+ expect(players[0].getAttribute("src")).toBe("case.json");
47
+ const script = context.file.document.querySelector(".contents > script");
48
+ expect(script === null || script === void 0 ? void 0 : script.getAttribute("src")).toBe(DataContentVisitor.SIGHTING_PLAYER_SCRIPT);
49
+ expect(script === null || script === void 0 ? void 0 : script.getAttribute("type")).toBe("module");
50
+ // Nothing else: no dated paragraph per sighting, which a sighting without a time used to fail the build on.
51
+ expect(context.file.document.querySelectorAll(".contents > p").length).toBe(1);
52
+ });
53
+ test("leaves a player the page's author placed alone", async () => {
54
+ const context = cmsTestUtil.newHtmlContext("people/v/VertongenJeanLuc/index.html", "<div class=\"contents\"><p>Le dossier</p><rr0-sighting src=\"case.json\" show-witness-map></rr0-sighting></div>");
55
+ const visitor = new TestDataContentVisitor();
56
+ await visitor.renderEvent(context, sightingEvent, aCase);
57
+ expect(context.file.document.querySelectorAll("rr0-sighting").length).toBe(1);
58
+ expect(context.file.document.querySelectorAll("script").length).toBe(0);
59
+ });
34
60
  });
@@ -9,9 +9,16 @@ export declare class TimeLinkDefaultHandler implements LinkHandler<HtmlRR0Contex
9
9
  protected urlBuilder: TimeUrlBuilder;
10
10
  protected timeTextBuilder: TimeTextBuilder;
11
11
  constructor(service: TimeService, urlBuilder: TimeUrlBuilder, timeTextBuilder: TimeTextBuilder);
12
+ /**
13
+ * The period containing the page (the year of a month, the month of a day...), which lists it.
14
+ */
12
15
  contents(context: HtmlRR0Context): Link | undefined;
13
16
  next(context: HtmlRR0Context): Link | undefined;
14
17
  prev(context: HtmlRR0Context): Link | undefined;
15
18
  start(context: HtmlRR0Context): Link | undefined;
19
+ /**
20
+ * A decade, century or millennium cannot be told from a year by its path's date, so its page's own title is used.
21
+ */
22
+ protected titleOf(context: HtmlRR0Context, fileName: string): string | undefined;
16
23
  protected isTimeFile(fileName: string): boolean;
17
24
  }
@@ -1,19 +1,25 @@
1
1
  import { LinkType } from "ssg-api";
2
+ import { existsSync, readFileSync } from "fs";
2
3
  export class TimeLinkDefaultHandler {
3
4
  constructor(service, urlBuilder, timeTextBuilder) {
4
5
  this.service = service;
5
6
  this.urlBuilder = urlBuilder;
6
7
  this.timeTextBuilder = timeTextBuilder;
7
8
  }
9
+ /**
10
+ * The period containing the page (the year of a month, the month of a day...), which lists it.
11
+ */
8
12
  contents(context) {
9
- const prevLink = this.prev(context);
10
- if (prevLink) {
11
- const urlBuilder = this.urlBuilder;
12
- let contentUrl = urlBuilder.matchExistingTimeFile(prevLink.url.substring(1));
13
- if (contentUrl != urlBuilder.options.rootDir) {
14
- const text = this.service.titleFromFile(context, contentUrl, this.timeTextBuilder);
13
+ const fileName = context.file.name;
14
+ if (this.isTimeFile(fileName)) {
15
+ const pageDir = fileName.substring(0, fileName.lastIndexOf("/"));
16
+ const parentDir = pageDir.substring(0, pageDir.lastIndexOf("/"));
17
+ const contentUrl = this.urlBuilder.matchExistingTimeFile(parentDir);
18
+ if (contentUrl) {
19
+ const contentFile = contentUrl + "/index.html";
20
+ const text = this.titleOf(context, contentFile);
15
21
  if (text) {
16
- return { type: LinkType.prev, text, url: "/" + contentUrl };
22
+ return { type: LinkType.contents, text, url: "/" + contentFile };
17
23
  }
18
24
  }
19
25
  }
@@ -26,7 +32,7 @@ export class TimeLinkDefaultHandler {
26
32
  if (pos >= 0) {
27
33
  const nextFile = service.files[pos + 1];
28
34
  if (nextFile) {
29
- const text = service.titleFromFile(context, nextFile, this.timeTextBuilder);
35
+ const text = this.titleOf(context, nextFile);
30
36
  return { type: LinkType.next, text, url: "/" + nextFile };
31
37
  }
32
38
  }
@@ -40,7 +46,7 @@ export class TimeLinkDefaultHandler {
40
46
  if (pos >= 0) {
41
47
  const prevFile = service.files[pos - 1];
42
48
  if (prevFile) {
43
- const text = service.titleFromFile(context, prevFile, this.timeTextBuilder);
49
+ const text = this.titleOf(context, prevFile);
44
50
  if (text) {
45
51
  return { type: LinkType.prev, text, url: "/" + prevFile };
46
52
  }
@@ -51,13 +57,28 @@ export class TimeLinkDefaultHandler {
51
57
  start(context) {
52
58
  if (this.isTimeFile(context.file.name)) {
53
59
  return {
54
- type: LinkType.contents,
60
+ type: LinkType.start,
55
61
  text: "Chronologie",
56
62
  url: "/time/"
57
63
  };
58
64
  }
59
65
  }
66
+ /**
67
+ * A decade, century or millennium cannot be told from a year by its path's date, so its page's own title is used.
68
+ */
69
+ titleOf(context, fileName) {
70
+ var _a;
71
+ const rootDir = this.urlBuilder.options.rootDir;
72
+ const digits = fileName.substring(rootDir.length + 1).split("/").length - 1;
73
+ if (digits < 4 && existsSync(fileName)) {
74
+ const title = (_a = /<title>([^<]*)<\/title>/.exec(readFileSync(fileName, "utf-8"))) === null || _a === void 0 ? void 0 : _a[1].trim();
75
+ if (title) {
76
+ return title;
77
+ }
78
+ }
79
+ return this.service.titleFromFile(context, fileName, this.timeTextBuilder);
80
+ }
60
81
  isTimeFile(fileName) {
61
- return fileName.startsWith("time/");
82
+ return fileName.startsWith(this.urlBuilder.options.rootDir + "/");
62
83
  }
63
84
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { LinkType } from "ssg-api";
3
+ import { cmsTestUtil } from "../test/CMSTestUtil.js";
4
+ import { TimeLinkDefaultHandler } from "./TimeLinkDefaultHandler.js";
5
+ describe("TimeLinkDefaultHandler", () => {
6
+ const rootDir = cmsTestUtil.filePath("time");
7
+ const files = [
8
+ "1/9/5/4/10/index.html",
9
+ "1/9/7/index.html",
10
+ "1/9/7/0/index.html",
11
+ "1/9/7/0/03/index.html",
12
+ "1/9/7/0/11/index.html",
13
+ "1/9/9/0/index.html"
14
+ ].map(file => rootDir + "/" + file);
15
+ const url = (file) => "/" + rootDir + "/" + file;
16
+ const timeUtil = cmsTestUtil.time;
17
+ const service = timeUtil.getService({ rootDir, files });
18
+ const handler = new TimeLinkDefaultHandler(service, timeUtil.urlBuilder, timeUtil.timeTextBuilder);
19
+ const context = cmsTestUtil.newHtmlContext("time/1/9/7/0/11/index.html", "<html><body></body></html>");
20
+ test("start is the chronology", () => {
21
+ expect(handler.start(context)).toEqual({ type: LinkType.start, text: "Chronologie", url: "/time/" });
22
+ });
23
+ test("contents is the period containing the page, not the previous page", () => {
24
+ const contents = handler.contents(context);
25
+ expect(contents.type).toBe(LinkType.contents);
26
+ expect(contents.url).toBe(url("1/9/7/0/index.html"));
27
+ expect(contents.text).toBe("1970");
28
+ });
29
+ test("prev and next follow the files order", () => {
30
+ expect(handler.prev(context).url).toBe(url("1/9/7/0/03/index.html"));
31
+ expect(handler.next(context).url).toBe(url("1/9/9/0/index.html"));
32
+ });
33
+ test("a decade is titled by its page, not as the year its path starts with", () => {
34
+ const yearContext = cmsTestUtil.newHtmlContext("time/1/9/7/0/index.html", "<html><body></body></html>");
35
+ const contents = handler.contents(yearContext);
36
+ expect(contents.url).toBe(url("1/9/7/index.html"));
37
+ expect(contents.text).toBe("1970s");
38
+ });
39
+ test("a page below a period without page is listed by the nearest one", () => {
40
+ const dayContext = cmsTestUtil.newHtmlContext("time/1/9/5/4/10/01/index.html", "<html><body></body></html>");
41
+ expect(handler.contents(dayContext).url).toBe(url("1/9/5/4/10/index.html"));
42
+ });
43
+ });
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.66",
5
+ "version": "0.3.68",
6
6
  "description": "RR0 Content Management System (CMS)",
7
7
  "exports": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",