@rr0/cms 0.3.73 → 0.3.75

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.
@@ -4,7 +4,7 @@ export class APIFactory extends TypedDataFactory {
4
4
  super(eventFactory, "api", ["index"]);
5
5
  }
6
6
  parse(apiJson) {
7
- const events = apiJson.events.map(this.eventFactory.parse);
8
- return { type: "api", id: apiJson.id, dirName: apiJson.dirName, url: apiJson.url, events };
7
+ const events = (apiJson.events || []).map(this.eventFactory.parse);
8
+ return { type: "api", id: apiJson.id, dirName: apiJson.dirName, title: apiJson.title, url: apiJson.url, events };
9
9
  }
10
10
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { RR0EventFactory } from "@rr0/data";
3
+ import { APIFactory } from "./APIFactory.js";
4
+ describe("APIFactory", () => {
5
+ const factory = new APIFactory(new RR0EventFactory());
6
+ test("keeps the title, which becomes the page title", () => {
7
+ const api = factory.parse({ type: "api", title: "JNLP", url: "https://www.jcp.org/en/jsr/detail?id=56" });
8
+ expect(api.title).toBe("JNLP");
9
+ expect(api.url).toBe("https://www.jcp.org/en/jsr/detail?id=56");
10
+ });
11
+ test("accepts an api.json without events", () => {
12
+ const api = factory.parse({ type: "api", title: "JOGL" });
13
+ expect(api.events).toEqual([]);
14
+ });
15
+ });
@@ -31,6 +31,11 @@ export type RR0TimeMessages = {
31
31
  * The word that, written right before an interval, asks for it to be rendered as `betweenAnd` ("between", "entre").
32
32
  */
33
33
  between: RegExp;
34
+ /**
35
+ * The words that, written right before a time, need it rendered in full: a relative wording can't follow them
36
+ * ("en l'année suivante", "in the year after").
37
+ */
38
+ preposition: RegExp;
34
39
  betweenAnd(startReplacement: string, endReplacement: string): string;
35
40
  on(approximate: boolean): string;
36
41
  in(approximate: boolean): string;
@@ -31,6 +31,7 @@ export declare class RR0TimeMessages_en implements RR0TimeMessages {
31
31
  in: (approximate: boolean) => string;
32
32
  fromTo: (startReplacement: string, endReplacement: string) => string;
33
33
  between: RegExp;
34
+ preposition: RegExp;
34
35
  betweenAnd: (startReplacement: string, endReplacement: string) => string;
35
36
  starting: (approximate: boolean) => string;
36
37
  }
@@ -34,6 +34,7 @@ export class RR0TimeMessages_en {
34
34
  this.in = (approximate) => (approximate ? "around " : "");
35
35
  this.fromTo = (startReplacement, endReplacement) => startReplacement + " to " + endReplacement;
36
36
  this.between = /\bbetween\s*$/i;
37
+ this.preposition = /\b(?:in|on|at|from|to|until|till|since|by|before|after|during|of|for|between|the)\s*$/i;
37
38
  this.betweenAnd = (startReplacement, endReplacement) => startReplacement + " and " + endReplacement;
38
39
  this.starting = (approximate) => "starting " + (approximate ? "" : "the ");
39
40
  }
@@ -31,6 +31,7 @@ export declare class RR0TimeMessages_fr implements RR0TimeMessages {
31
31
  in: (approximate: boolean) => string;
32
32
  fromTo: (startReplacement: string, endReplacement: string) => string;
33
33
  between: RegExp;
34
+ preposition: RegExp;
34
35
  betweenAnd: (startReplacement: string, endReplacement: string) => string;
35
36
  starting: (approximate: boolean) => string;
36
37
  }
@@ -34,6 +34,7 @@ export class RR0TimeMessages_fr {
34
34
  this.in = (approximate) => (approximate ? "vers " : "en ");
35
35
  this.fromTo = (startReplacement, endReplacement) => `${startReplacement} à ${endReplacement}`;
36
36
  this.between = /\bentre\s*$/i;
37
+ this.preposition = /(?:^|[^\p{L}])(?:(?:en|de|du|des|à|au|aux|jusque|dès|depuis|vers|avant|après|pour|par|fin|début|entre|le|la|les|ce|cet|cette)\s*|(?:d|l|jusqu|qu)['’]\s*|mi-\s*)$/iu;
37
38
  this.betweenAnd = (startReplacement, endReplacement) => `${startReplacement} et ${endReplacement}`;
38
39
  this.starting = (approximate) => "à partir " + (approximate ? "de " : "du ");
39
40
  }
@@ -6,10 +6,25 @@ import { HtmlRR0Context } from "../RR0Context.js";
6
6
  */
7
7
  export declare class SsiTitleReplaceCommand extends SsiEchoVarReplaceCommand {
8
8
  protected defaultHandlers: StringContextHandler[];
9
+ protected static readonly setVarRegex: RegExp;
10
+ /**
11
+ * The title set by the file being processed, if any.
12
+ */
13
+ protected setTitle: string | undefined;
9
14
  /**
10
15
  * @param defaultHandlers Will generate a title for a given context/file, if no title is found.
11
16
  */
12
17
  constructor(defaultHandlers?: StringContextHandler[]);
18
+ /**
19
+ * Consumes a `<!--#set var="title" value="..." -->` directive first, as it would otherwise be read only after the
20
+ * title has been resolved (and then output as a second `<title>` inside the page's body).
21
+ */
22
+ execute(context: HtmlRR0Context): Promise<void>;
13
23
  protected createReplacer(context: HtmlRR0Context): Promise<RegexReplacer>;
14
24
  protected getTitle(context: HtmlRR0Context): string;
25
+ /**
26
+ * @return The title implied by a file name: its directory's name for an index page (org/eu/fr/.../paris/index.html
27
+ * gives "Paris"), its own name otherwise.
28
+ */
29
+ protected nameTitle(fileName: string): string;
15
30
  }
@@ -1,4 +1,6 @@
1
1
  import { SsiEchoVarReplaceCommand } from "ssg-api";
2
+ import { StringUtil } from "../util/string/StringUtil.js";
3
+ import path from "path";
2
4
  /**
3
5
  * Replaces the SSI expression "<!--#echo var="title" -->" by the page's <title> content,
4
6
  * with a link if there's a <meta name="url"> content.
@@ -11,6 +13,19 @@ export class SsiTitleReplaceCommand extends SsiEchoVarReplaceCommand {
11
13
  super("title");
12
14
  this.defaultHandlers = defaultHandlers;
13
15
  }
16
+ /**
17
+ * Consumes a `<!--#set var="title" value="..." -->` directive first, as it would otherwise be read only after the
18
+ * title has been resolved (and then output as a second `<title>` inside the page's body).
19
+ */
20
+ async execute(context) {
21
+ const contents = context.file.contents;
22
+ const match = SsiTitleReplaceCommand.setVarRegex.exec(contents);
23
+ this.setTitle = match === null || match === void 0 ? void 0 : match[1];
24
+ if (match) {
25
+ context.file.contents = contents.replace(match[0], "");
26
+ }
27
+ await super.execute(context);
28
+ }
14
29
  async createReplacer(context) {
15
30
  return {
16
31
  replace: (_match, ..._args) => {
@@ -22,13 +37,23 @@ export class SsiTitleReplaceCommand extends SsiEchoVarReplaceCommand {
22
37
  };
23
38
  }
24
39
  getTitle(context) {
25
- let title = context.file.title;
40
+ let title = context.file.title || this.setTitle;
26
41
  if (!title) {
27
42
  this.defaultHandlers.some(handle => !title && (title = handle(context)));
28
43
  }
29
44
  if (!title) {
30
- title = context.file.name;
45
+ title = this.nameTitle(context.file.name);
31
46
  }
32
47
  return title;
33
48
  }
49
+ /**
50
+ * @return The title implied by a file name: its directory's name for an index page (org/eu/fr/.../paris/index.html
51
+ * gives "Paris"), its own name otherwise.
52
+ */
53
+ nameTitle(fileName) {
54
+ const baseName = path.basename(fileName, path.extname(fileName));
55
+ const name = /^index(_[a-z]{2})?$/.test(baseName) ? path.basename(path.dirname(fileName)) : baseName;
56
+ return StringUtil.camelToText(name);
57
+ }
34
58
  }
59
+ SsiTitleReplaceCommand.setVarRegex = /<!--\s*#set\s+var="title"\s+value="(.+?)"\s*-->\s*/s;
@@ -7,6 +7,10 @@ export declare class TimeService extends AbstractDataService<RR0Event, RR0EventJ
7
7
  protected options: TimeOptions;
8
8
  readonly timePathRegex: RegExp;
9
9
  static readonly defaultRegex: RegExp;
10
+ /**
11
+ * A year beyond 4 digits, which has its own directory (time/-10000/index.html).
12
+ */
13
+ static readonly yearDirRegex: RegExp;
10
14
  constructor(dataService: AllDataService, options: TimeOptions, timePathRegex?: RegExp);
11
15
  parseFileName(fileName: string): RegExpExecArray | null;
12
16
  titleFromFile(context: HtmlRR0Context, fileName: string, timeTextBuilder: TimeTextBuilder): string | undefined;
@@ -22,6 +22,16 @@ export class TimeService extends AbstractDataService {
22
22
  }
23
23
  contextFromFileName(context, fileName = context.file.name) {
24
24
  let timeContext;
25
+ const yearDirExec = TimeService.yearDirRegex.exec(fileName);
26
+ if (yearDirExec) {
27
+ timeContext = context.clone().time;
28
+ timeContext.setYear(parseInt(yearDirExec[1], 10));
29
+ timeContext.setMonth(undefined);
30
+ timeContext.setDayOfMonth(undefined);
31
+ timeContext.setHour(undefined);
32
+ timeContext.setMinutes(undefined);
33
+ return timeContext;
34
+ }
25
35
  let elems;
26
36
  if (fileName.endsWith("index.html")) {
27
37
  while ((elems = fileName.split("/")).length < 6) {
@@ -64,3 +74,7 @@ export class TimeService extends AbstractDataService {
64
74
  }
65
75
  }
66
76
  TimeService.defaultRegex = /time\/(-)?(\d)\/(\d)\/(\d)\/(\d)\/?(\d{2})?\/?(\d{2})?\/?(index(_[a-z]{2})?.html)?/;
77
+ /**
78
+ * A year beyond 4 digits, which has its own directory (time/-10000/index.html).
79
+ */
80
+ TimeService.yearDirRegex = /time\/(-?\d{5,})\/(index(_[a-z]{2})?\.html)?$/;
@@ -10,9 +10,14 @@ describe("TitleReplaceCommand", () => {
10
10
  const command = new SsiTitleReplaceCommand();
11
11
  const context = cmsTestUtil.newHtmlContext(fileName, `This is about <!--#echo var="title" -->!`);
12
12
  await command.execute(context);
13
- const fullPath = cmsTestUtil.filePath(fileName);
14
- expect(context.file.title).toBe(fullPath);
15
- expect(context.file.contents).toBe(`<html><head><title>${cmsTestUtil.filePath(fileName)}</title></head><body>This is about ${fullPath}!</body></html>`);
13
+ expect(context.file.title).toBe("4");
14
+ expect(context.file.contents).toBe(`<html><head><title>4</title></head><body>This is about 4!</body></html>`);
15
+ });
16
+ test("year beyond 4 digits", async () => {
17
+ const command = new SsiTitleReplaceCommand([timeDefaultHandler]);
18
+ const context = cmsTestUtil.newHtmlContext("time/-10000/index.html", `This is about <!--#echo var="title" -->!`);
19
+ await command.execute(context);
20
+ expect(context.file.title).toBe("10000 av. J.-C.");
16
21
  });
17
22
  test("default title with handler", async () => {
18
23
  const command = new SsiTitleReplaceCommand([timeDefaultHandler]);
@@ -21,6 +26,13 @@ describe("TitleReplaceCommand", () => {
21
26
  expect(context.file.title).toBe("1954");
22
27
  expect(context.file.contents).toBe(`<html><head><title>1954</title></head><body>This is about 1954!</body></html>`);
23
28
  });
29
+ test("title set by a SSI directive prevails over the default handler", async () => {
30
+ const command = new SsiTitleReplaceCommand([timeDefaultHandler]);
31
+ const context = cmsTestUtil.newHtmlContext(fileName, `<!--#set var="title" value="Les visiteurs du ciel" -->\nThis is about <!--#echo var="title" -->!`);
32
+ await command.execute(context);
33
+ expect(context.file.title).toBe("Les visiteurs du ciel");
34
+ expect(context.file.contents).toBe(`<html><head><title>Les visiteurs du ciel</title></head><body>This is about Les visiteurs du ciel!</body></html>`);
35
+ });
24
36
  test("default month title with handler", async () => {
25
37
  const command = new SsiTitleReplaceCommand([timeDefaultHandler]);
26
38
  const context = cmsTestUtil.time.newHtmlContext("1/9/5/4/10/index.html", `This is about <!--#echo var="title" -->!`);
@@ -36,4 +48,10 @@ describe("TitleReplaceCommand", () => {
36
48
  expect(context.file.contents).toBe(`<html><head><title>Vendredi 1 octobre 1954</title></head><body>This is about Vendredi 1 octobre 1954!</body></html>`);
37
49
  });
38
50
  });
51
+ test("directory name of an index page with no title", async () => {
52
+ const command = new SsiTitleReplaceCommand([timeDefaultHandler]);
53
+ const context = cmsTestUtil.newHtmlContext("org/eu/fr/cobeps/index.html", `This is about <!--#echo var="title" -->!`);
54
+ await command.execute(context);
55
+ expect(context.file.title).toBe("Cobeps");
56
+ });
39
57
  });
@@ -33,8 +33,14 @@ export class TimeReplacer {
33
33
  const previousContext = origEl.dataset.context === "none" ? undefined : context.clone();
34
34
  const timeStr = origEl.textContent;
35
35
  const { prefix, value, suffix } = this.completer.split(timeStr);
36
- const between = context.messages.context.time.between.test(TimeReplacer.precedingText(origEl) + prefix);
37
- const options = { url: true, contentOnly: true, between };
36
+ const preceding = TimeReplacer.precedingText(origEl) + prefix;
37
+ const between = context.messages.context.time.between.test(preceding);
38
+ // "en l'année suivante" is not French: after a preposition, the time is rendered in full
39
+ const relativeWords = !context.messages.context.time.preposition.test(preceding);
40
+ // A <time> already inside a link keeps that link: a link of its own would nest <a> in <a>, which the HTML
41
+ // parser splits, closing the enclosing elements (a note, a source) early.
42
+ const url = !origEl.closest("a");
43
+ const options = { url, contentOnly: true, between, relativeWords };
38
44
  try {
39
45
  const durations = /^(~?)P([^/]+)\/P?([^/]+)$/.exec(value.trim()); // "P10M/12M"
40
46
  if (durations) {
@@ -213,6 +213,30 @@ describe("TimeReplacer", async () => {
213
213
  const replacement2 = await replacer.replacement(context, original);
214
214
  expect(replacement2.outerHTML).toBe(`<span class="time-resolved"><a href="${cmsTestUtil.time.url("2/0/0/6/")}"><time datetime="${datetime}" title="2006">l'année suivante</time></a></span>`);
215
215
  });
216
+ describe("renders a time in full after a preposition", () => {
217
+ async function renderAfter(locale, html) {
218
+ const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "", locale);
219
+ const p = context.file.document.createElement("p");
220
+ p.innerHTML = html;
221
+ const times = p.querySelectorAll("time");
222
+ let replaced;
223
+ for (const time of times) {
224
+ replaced = await replacer.replacement(context, time);
225
+ }
226
+ return replaced.textContent;
227
+ }
228
+ test("in French", async () => {
229
+ expect(await renderAfter("fr", "<time>2005</time>. En <time>2006</time>")).toBe("2006");
230
+ expect(await renderAfter("fr", "<time>2005</time>, jusqu'à <time>2006</time>")).toBe("2006");
231
+ expect(await renderAfter("fr", "<time>2005</time>, d’<time>2006</time>")).toBe("2006");
232
+ });
233
+ test("in English", async () => {
234
+ expect(await renderAfter("en", "<time>2005</time>. In <time>2006</time>")).toBe("2006");
235
+ });
236
+ test("but not without one", async () => {
237
+ expect(await renderAfter("fr", "<time>2005</time>. Puis, <time>2006</time>")).toBe("l'année suivante");
238
+ });
239
+ });
216
240
  test("avoids linking to current file", async () => {
217
241
  const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
218
242
  const timeEl = context.file.document.createElement("time");
@@ -341,6 +365,19 @@ describe("TimeReplacer", async () => {
341
365
  const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
342
366
  expect(await render(context, "1947-06-24 21:45PST")).toBe("mardi 24 juin 1947 à 21:45");
343
367
  });
368
+ test("time inside a link gets no link of its own", async () => {
369
+ const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
370
+ const doc = context.file.document;
371
+ const a = doc.createElement("a");
372
+ a.href = "/science/crypto/ufo/enquete/dossier/5Novembre/index.html";
373
+ const timeEl = doc.createElement("time");
374
+ timeEl.textContent = "2003";
375
+ a.append(timeEl);
376
+ doc.body.append(a);
377
+ const replacement = await replacer.replacement(context, timeEl);
378
+ expect(replacement.querySelector("a")).toBe(null);
379
+ expect(replacement.textContent).toBe("2003");
380
+ });
344
381
  test("season does not break the next time", async () => {
345
382
  const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
346
383
  expect(await render(context, "1990-21")).toBe("printemps 1990");
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.73",
5
+ "version": "0.3.75",
6
6
  "description": "RR0 Content Management System (CMS)",
7
7
  "exports": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
@@ -31,7 +31,7 @@
31
31
  "dependencies": {
32
32
  "@javarome/fileutil": "^0.3.9",
33
33
  "@rr0/common": "^1.1.5",
34
- "@rr0/data": "^0.3.43",
34
+ "@rr0/data": "^0.3.45",
35
35
  "@rr0/lang": "^0.1.13",
36
36
  "@rr0/place": "^0.5.4",
37
37
  "@rr0/time": "^0.11.3",