@rr0/cms 0.3.71 → 0.3.73

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.
@@ -287,10 +287,15 @@ export class TimeContext {
287
287
  * @return {TimeContext}
288
288
  */
289
289
  clone() {
290
- var _a, _b, _c, _d, _e, _f;
290
+ var _a, _b, _c, _d, _e, _f, _g;
291
291
  const date = this.date;
292
292
  const interval = this.interval;
293
- return new TimeContext((_a = date === null || date === void 0 ? void 0 : date.year) === null || _a === void 0 ? void 0 : _a.value, (_b = date === null || date === void 0 ? void 0 : date.month) === null || _b === void 0 ? void 0 : _b.value, (_c = date === null || date === void 0 ? void 0 : date.day) === null || _c === void 0 ? void 0 : _c.value, (_d = date === null || date === void 0 ? void 0 : date.hour) === null || _d === void 0 ? void 0 : _d.value, (_e = date === null || date === void 0 ? void 0 : date.minute) === null || _e === void 0 ? void 0 : _e.value, (_f = date === null || date === void 0 ? void 0 : date.timeshift) === null || _f === void 0 ? void 0 : _f.value, this.approximate, this.approximateTime, interval === null || interval === void 0 ? void 0 : interval.from, interval === null || interval === void 0 ? void 0 : interval.to, this.duration);
293
+ const season = ((_a = date === null || date === void 0 ? void 0 : date.month) === null || _a === void 0 ? void 0 : _a.value) > 12; // "1954-23": EdtfDate's constructor rejects it, only its parser accepts it
294
+ const clone = new TimeContext((_b = date === null || date === void 0 ? void 0 : date.year) === null || _b === void 0 ? void 0 : _b.value, season ? undefined : (_c = date === null || date === void 0 ? void 0 : date.month) === null || _c === void 0 ? void 0 : _c.value, (_d = date === null || date === void 0 ? void 0 : date.day) === null || _d === void 0 ? void 0 : _d.value, (_e = date === null || date === void 0 ? void 0 : date.hour) === null || _e === void 0 ? void 0 : _e.value, (_f = date === null || date === void 0 ? void 0 : date.minute) === null || _f === void 0 ? void 0 : _f.value, (_g = date === null || date === void 0 ? void 0 : date.timeshift) === null || _g === void 0 ? void 0 : _g.value, this.approximate, this.approximateTime, interval === null || interval === void 0 ? void 0 : interval.from, interval === null || interval === void 0 ? void 0 : interval.to, this.duration);
295
+ if (season) {
296
+ clone.date = EdtfDate.fromString(date.toString());
297
+ }
298
+ return clone;
294
299
  }
295
300
  /**
296
301
  *
@@ -7,7 +7,7 @@ export declare class TimeElementFactory {
7
7
  readonly renderer: TimeRenderer;
8
8
  constructor(renderer: TimeRenderer);
9
9
  create(context: HtmlRR0Context, previousContext: HtmlRR0Context | undefined, options: TimeRenderOptions): HTMLElement | undefined;
10
- protected createInterval(fromContext: HtmlRR0Context, toContext: HtmlRR0Context, previousContext: HtmlRR0Context, options: TimeRenderOptions): HTMLElement | undefined;
10
+ createInterval(fromContext: HtmlRR0Context, toContext: HtmlRR0Context, previousContext: HtmlRR0Context, options: TimeRenderOptions): HTMLElement | undefined;
11
11
  protected createStarting(fromContext: HtmlRR0Context, previousContext: HtmlRR0Context, options: TimeRenderOptions): HTMLElement | undefined;
12
12
  protected valueReplacement(context: HtmlRR0Context, previousContext: RR0Context | undefined, options?: TimeRenderOptions): HTMLElement | undefined;
13
13
  protected durationReplacement(context: HtmlRR0Context): HTMLTimeElement | undefined;
@@ -31,9 +31,16 @@ export class TimeElementFactory {
31
31
  }
32
32
  createInterval(fromContext, toContext, previousContext, options) {
33
33
  let replacement;
34
+ options = { ...options, relativeWords: false }; // "the day after at 22 h" can't start or end an interval
34
35
  const startReplacement = this.valueReplacement(fromContext, previousContext, options);
35
36
  if (startReplacement) {
36
- const endReplacement = this.valueReplacement(toContext, previousContext, options);
37
+ const from = fromContext.time;
38
+ const to = toContext.time;
39
+ const sameDay = from.getDayOfMonth() && from.getYear() === to.getYear() && from.getMonth() === to.getMonth()
40
+ && from.getDayOfMonth() === to.getDayOfMonth();
41
+ const endReplacement = sameDay // "21:00/22:00" ends at "22 h", not at "Tuesday 22 May 1517 at 22 h"
42
+ ? this.valueReplacement(toContext, fromContext, options)
43
+ : this.valueReplacement(toContext, previousContext, options);
37
44
  if (endReplacement && endReplacement.outerHTML !== startReplacement.outerHTML) {
38
45
  replacement = fromContext.file.document.createElement("span");
39
46
  replacement.className = "time-interval";
@@ -9,6 +9,10 @@ export interface TimeRenderOptions {
9
9
  * The text says "between" right before this interval, so it reads "between X and Y" rather than "between X to Y".
10
10
  */
11
11
  between?: boolean;
12
+ /**
13
+ * If a wording like "the day after" may replace the time. Defaults to true.
14
+ */
15
+ relativeWords?: boolean;
12
16
  }
13
17
  export declare class TimeRenderer {
14
18
  readonly urlBuilder: TimeUrlBuilder;
@@ -23,7 +23,7 @@ export class TimeRenderer {
23
23
  const date = time.date;
24
24
  const absoluteTimeUrl = this.urlBuilder.fromEdtf(date);
25
25
  const title = this.textBuilder.build(context, renderOptions);
26
- const text = (previousContext ? this.relativeTextBuilder.build(previousContext, context) : undefined) || title;
26
+ const text = (previousContext ? this.relativeTextBuilder.build(previousContext, context, options.relativeWords !== false) : undefined) || title;
27
27
  const file = context.file;
28
28
  const currentFileName = file.name;
29
29
  const doc = file.document;
@@ -1,6 +1,8 @@
1
1
  import { HtmlRR0Context } from "../../RR0Context.js";
2
2
  import { DomReplacement } from "../DomReplacement.js";
3
3
  import { TimeElementFactory } from "./TimeElementFactory.js";
4
+ import { TimeRenderOptions } from "./TimeRenderer.js";
5
+ import { TimeStringCompleter } from "./TimeStringCompleter.js";
4
6
  /**
5
7
  * Replaces a <time> tag.
6
8
  */
@@ -11,6 +13,7 @@ export declare class TimeReplacer implements DomReplacement<HtmlRR0Context, HTML
11
13
  * ("between <strong><time>1989/1994</time></strong>").
12
14
  */
13
15
  static readonly inlineTags: string[];
16
+ protected readonly completer: TimeStringCompleter;
14
17
  constructor(factory: TimeElementFactory);
15
18
  static resolvedTime(context: HtmlRR0Context, dateTime: string): HTMLTimeElement;
16
19
  /**
@@ -18,4 +21,8 @@ export declare class TimeReplacer implements DomReplacement<HtmlRR0Context, HTML
18
21
  */
19
22
  static precedingText(el: Element): string;
20
23
  replacement(context: HtmlRR0Context, origEl: HTMLTimeElement): Promise<HTMLElement>;
24
+ /**
25
+ * Renders a range of durations ("P10M/12M") as "from 10 to 12 minutes".
26
+ */
27
+ protected durationRange(context: HtmlRR0Context, previousContext: HtmlRR0Context | undefined, durations: RegExpExecArray, options: TimeRenderOptions): HTMLElement | undefined;
21
28
  }
@@ -1,10 +1,12 @@
1
1
  import { ObjectUtils } from "@rr0/common";
2
+ import { TimeStringCompleter } from "./TimeStringCompleter.js";
2
3
  /**
3
4
  * Replaces a <time> tag.
4
5
  */
5
6
  export class TimeReplacer {
6
7
  constructor(factory) {
7
8
  this.factory = factory;
9
+ this.completer = new TimeStringCompleter();
8
10
  }
9
11
  static resolvedTime(context, dateTime) {
10
12
  const replacement = context.file.document.createElement("time");
@@ -24,23 +26,64 @@ export class TimeReplacer {
24
26
  }
25
27
  async replacement(context, origEl) {
26
28
  let replacement;
27
- if (origEl.dateTime) { // Already done?
29
+ if (origEl.dateTime || origEl.dataset.format === "none") { // Already done, or not to be interpreted
28
30
  replacement = origEl;
29
31
  }
30
32
  else {
31
33
  const previousContext = origEl.dataset.context === "none" ? undefined : context.clone();
32
34
  const timeStr = origEl.textContent;
33
- const valid = context.time.updateFromStr(timeStr);
34
- const between = context.messages.context.time.between.test(TimeReplacer.precedingText(origEl));
35
- replacement = valid && this.factory.create(context, previousContext, { url: true, contentOnly: true, between });
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 };
38
+ try {
39
+ const durations = /^(~?)P([^/]+)\/P?([^/]+)$/.exec(value.trim()); // "P10M/12M"
40
+ if (durations) {
41
+ replacement = this.durationRange(context, previousContext, durations, options);
42
+ }
43
+ else {
44
+ const completed = this.completer.complete(value.trim(), context.time);
45
+ if (!this.completer.isInterpretable(completed)) {
46
+ context.warn("Could not interpret time", timeStr);
47
+ }
48
+ else if (this.completer.isDayless(completed)) {
49
+ context.warn("Could not resolve the day of time", timeStr);
50
+ }
51
+ else if (context.time.updateFromStr(completed)) {
52
+ replacement = this.factory.create(context, previousContext, options);
53
+ }
54
+ }
55
+ }
56
+ catch (e) { // One unrenderable time must not stop the others of the page
57
+ context.warn("Could not render time", timeStr, e.message);
58
+ replacement = undefined;
59
+ }
36
60
  if (!replacement) {
37
61
  replacement = origEl;
38
- // replacement.setAttribute("datetime", context.time.toString())
62
+ }
63
+ else if (prefix || suffix) { // Keep the words written around the time ("vers", "le soir")
64
+ const wrapper = context.file.document.createElement("span");
65
+ wrapper.className = "time-described";
66
+ wrapper.append(prefix, replacement, suffix);
67
+ replacement = wrapper;
39
68
  }
40
69
  context.debug("\tReplacing time", origEl.outerHTML, "with", ObjectUtils.asSet(replacement).outerHTML);
41
70
  }
42
71
  return replacement;
43
72
  }
73
+ /**
74
+ * Renders a range of durations ("P10M/12M") as "from 10 to 12 minutes".
75
+ */
76
+ durationRange(context, previousContext, durations, options) {
77
+ const [, approximate, from, to] = durations;
78
+ const fromContext = context.clone();
79
+ const toContext = context.clone();
80
+ let replacement;
81
+ if (fromContext.time.updateFromStr(`${approximate}P${from.toUpperCase()}`)
82
+ && toContext.time.updateFromStr(`${approximate}P${to.toUpperCase()}`)) {
83
+ replacement = this.factory.createInterval(fromContext, toContext, previousContext, options);
84
+ }
85
+ return replacement;
86
+ }
44
87
  }
45
88
  /**
46
89
  * Elements that don't break a sentence, so that the text before them is the text before the <time> they contain
@@ -102,6 +102,36 @@ describe("TimeReplacer", async () => {
102
102
  expect(await renderIn("en", "from <time>1951/1955</time>")).toBe("1951 to 1955");
103
103
  });
104
104
  });
105
+ describe("renders an EDTF season", () => {
106
+ async function render(locale, value) {
107
+ const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "", locale);
108
+ const el = context.file.document.createElement("time");
109
+ el.textContent = value;
110
+ return (await replacer.replacement(context, el)).textContent;
111
+ }
112
+ test("in French and English, instead of failing on month 23", async () => {
113
+ expect(await render("fr", "1954-23")).toBe("automne 1954");
114
+ expect(await render("en", "1954-22")).toBe("summer 1954");
115
+ });
116
+ test("after another date", async () => {
117
+ const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
118
+ const previous = context.file.document.createElement("time");
119
+ previous.textContent = "1954-10-16";
120
+ await replacer.replacement(context, previous);
121
+ const season = context.file.document.createElement("time");
122
+ season.textContent = "1954-24";
123
+ expect((await replacer.replacement(context, season)).textContent).toBe("hiver 1954");
124
+ });
125
+ });
126
+ test("leaves a time with data-format=\"none\" as written", async () => {
127
+ const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
128
+ const el = context.file.document.createElement("time");
129
+ el.dataset.format = "none";
130
+ el.textContent = "Dans la nuit";
131
+ const replaced = await replacer.replacement(context, el);
132
+ expect(replaced).toBe(el);
133
+ expect(replaced.outerHTML).toBe(`<time data-format="none">Dans la nuit</time>`);
134
+ });
105
135
  test("parses unsupported", async () => {
106
136
  const interval = "moi";
107
137
  const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
@@ -252,4 +282,69 @@ describe("TimeReplacer", async () => {
252
282
  test("with approximation", async () => {
253
283
  });
254
284
  });
285
+ describe("partial values", () => {
286
+ async function render(context, str) {
287
+ const timeEl = context.file.document.createElement("time");
288
+ timeEl.textContent = str;
289
+ const replacement = await replacer.replacement(context, timeEl);
290
+ return replacement.textContent;
291
+ }
292
+ test("day of the current month", async () => {
293
+ const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
294
+ expect(await render(context, "19")).toBe("dimanche 19");
295
+ expect(context.time.toString()).toBe("1990-08-19");
296
+ });
297
+ test("hour of the current day", async () => {
298
+ const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
299
+ await render(context, "12");
300
+ expect(await render(context, "21:00")).toBe("21 h");
301
+ expect(context.time.getDayOfMonth()).toBe(12);
302
+ expect(context.time.getHour()).toBe(21);
303
+ expect(await render(context, "23T~01:45")).toBe("jeudi 23 01:45");
304
+ expect(await render(context, "00:19/00:30")).toBe("00:19 à 00:30");
305
+ expect(context.time.getYear()).toBe(1990);
306
+ });
307
+ test("hour of an unknown day is not read as a year", async () => {
308
+ const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/index.html", "");
309
+ expect(await render(context, "07:30")).toBe("07:30");
310
+ expect(context.time.getYear()).toBe(1990);
311
+ });
312
+ test("interval end relative to its start", async () => {
313
+ const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
314
+ expect(await render(context, "1517-05-22 21:00/22:00")).toBe("mardi 22 mai 1517 à 21 h à 22 h");
315
+ expect(await render(context, "02 20:00/03 05:00")).toBe("mercredi 2 20 h à jeudi 3 05 h");
316
+ expect(await render(context, "27 23:00/03:00")).toBe("dimanche 27 23 h à lundi 28 03 h");
317
+ });
318
+ test("date without hour after an hour of the previous day", async () => {
319
+ const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
320
+ await render(context, "1998-08-09 22:00");
321
+ expect(await render(context, "1998-08-10")).toBe("lundi 10");
322
+ });
323
+ test("range of durations", async () => {
324
+ const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
325
+ expect(await render(context, "P10M/12M")).toBe("10 minutes à 12 minutes");
326
+ expect(await render(context, "P15S/20s")).toBe("15 secondes à 20 secondes");
327
+ });
328
+ test("words around the value are kept", async () => {
329
+ const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
330
+ expect(await render(context, "1964-04-27Tle soir")).toBe("lundi 27 avril 1964 le soir");
331
+ expect(await render(context, "28Tpeu après minuit")).toBe("le lendemain peu après minuit");
332
+ expect(await render(context, "vers\u00a010:50")).toBe("vers\u00a010:50");
333
+ });
334
+ test("words are not read as a time", async () => {
335
+ const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
336
+ expect(await render(context, "1 h après le coucher du soleil")).toBe("1 h après le coucher du soleil");
337
+ expect(await render(context, "12 mars 1977")).toBe("12 mars 1977");
338
+ expect(context.time.toString()).toBe("1990-08");
339
+ });
340
+ test("time zone is part of the value", async () => {
341
+ const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
342
+ expect(await render(context, "1947-06-24 21:45PST")).toBe("mardi 24 juin 1947 à 21:45");
343
+ });
344
+ test("season does not break the next time", async () => {
345
+ const context = cmsTestUtil.time.newHtmlContext("1/9/9/0/08/index.html", "");
346
+ expect(await render(context, "1990-21")).toBe("printemps 1990");
347
+ expect(await render(context, "-0033")).toBe("33 av. J.-C.");
348
+ });
349
+ });
255
350
  });
@@ -0,0 +1,55 @@
1
+ import { TimeContext } from "../TimeContext.mjs";
2
+ /**
3
+ * A <time> string split into the time value itself and the free text around it ("vers 10:50", "03Tla nuit").
4
+ */
5
+ export interface TimeStringParts {
6
+ prefix: string;
7
+ value: string;
8
+ suffix: string;
9
+ }
10
+ /**
11
+ * Prepares a <time> string for EDTF parsing.
12
+ *
13
+ * RR0 pages write times relative to the page and to the previous time: "19" is the 19th of the current month,
14
+ * "21:00" an hour of the current day, "1517-05-22 21:00/22:00" ends at 22:00 the same day. EDTF alone reads "19"
15
+ * as the year 19 and "07:30" as the year 7, so such partial values are completed from the current time context
16
+ * first. Free text written around the value ("le soir", "vers") is set apart so that it can be kept as is.
17
+ */
18
+ export declare class TimeStringCompleter {
19
+ /**
20
+ * A trailing time zone ("PST", "(CDT)", "UTC+1") is part of the value, not free text.
21
+ */
22
+ protected static readonly timeZone: RegExp;
23
+ /**
24
+ * A duration ("P10M", "~P1H"), or a range of durations ("P10M/12M").
25
+ */
26
+ static readonly duration: RegExp;
27
+ split(str: string): TimeStringParts;
28
+ /**
29
+ * @param value A time value, possibly partial ("19", "21:00", "15 22:00/16 05:00").
30
+ * @param time The current time context, that the partial value is relative to.
31
+ * @return The completed value, or the value unchanged if it is not partial or cannot be completed.
32
+ */
33
+ complete(value: string, time: TimeContext): string;
34
+ /**
35
+ * EDTF parsing reads "1 h après le coucher" as the year 1 and ignores the rest: words must not reach it.
36
+ *
37
+ * @return If the value only holds characters of a time value (and possibly a trailing time zone).
38
+ */
39
+ isInterpretable(value: string): boolean;
40
+ /**
41
+ * @return If the value still holds a time of an unknown day ("07:30", "12 05:45"), which EDTF would read as a year.
42
+ */
43
+ isDayless(value: string): boolean;
44
+ protected completeOne(value: string, time: TimeContext): string;
45
+ protected isTime(value: string): boolean;
46
+ /**
47
+ * An end hour before its start hour ("23:00/03:00") is on the next day.
48
+ *
49
+ * @param end The end, completed with the day of its start ("1981-10-27 03:00").
50
+ * @param start The start ("1981-10-27 23:00").
51
+ */
52
+ protected dayAfterIfBefore(end: string, start: string): string;
53
+ protected year(year: number): string;
54
+ protected pad(n: number | string): string;
55
+ }
@@ -0,0 +1,128 @@
1
+ import { TimeContext } from "../TimeContext.mjs";
2
+ /**
3
+ * Prepares a <time> string for EDTF parsing.
4
+ *
5
+ * RR0 pages write times relative to the page and to the previous time: "19" is the 19th of the current month,
6
+ * "21:00" an hour of the current day, "1517-05-22 21:00/22:00" ends at 22:00 the same day. EDTF alone reads "19"
7
+ * as the year 19 and "07:30" as the year 7, so such partial values are completed from the current time context
8
+ * first. Free text written around the value ("le soir", "vers") is set apart so that it can be kept as is.
9
+ */
10
+ export class TimeStringCompleter {
11
+ split(str) {
12
+ let prefix = "";
13
+ let value = str.replace(/[ \t\r\n]+/g, " ").trim(); // "1973-10-11\n 21:00/22:00"
14
+ let suffix = "";
15
+ if (!TimeStringCompleter.duration.test(value.trim())) {
16
+ const pre = /^([^\d~-]*[A-Za-zÀ-ÿ][^\d~]*?)(\s+)(~?\d.*)$/s.exec(value); // "vers 10:50", "Environ 00:30"
17
+ if (pre) {
18
+ prefix = pre[1] + pre[2];
19
+ value = pre[3];
20
+ }
21
+ }
22
+ const post = /^(.*?\d)(\s*T\s*|\s+)([^\d\s~].*)$/s.exec(value); // "03Tla nuit", "1976-08-03 le soir"
23
+ // A bare number followed by a space may be a quantity ("1 h", "30 mn après", "8 jours plus tard"), so a day of the
24
+ // month needs a T before its text ("18Tla nuit").
25
+ const quantity = post && /^\d{1,2}$/.test(post[1]) && !post[2].includes("T");
26
+ if (post && !quantity && !TimeStringCompleter.timeZone.test(post[3].trim())) {
27
+ value = post[1];
28
+ suffix = (post[2].includes("T") ? " " : post[2]) + post[3];
29
+ }
30
+ return { prefix, value, suffix };
31
+ }
32
+ /**
33
+ * @param value A time value, possibly partial ("19", "21:00", "15 22:00/16 05:00").
34
+ * @param time The current time context, that the partial value is relative to.
35
+ * @return The completed value, or the value unchanged if it is not partial or cannot be completed.
36
+ */
37
+ complete(value, time) {
38
+ const slash = value.indexOf("/");
39
+ if (slash > 0 && !TimeStringCompleter.duration.test(value)) {
40
+ const start = this.completeOne(value.substring(0, slash).trim(), time);
41
+ const startTime = new TimeContext();
42
+ if (!startTime.updateFromStr(start) || !startTime.date) {
43
+ return value;
44
+ }
45
+ let end = value.substring(slash + 1).trim();
46
+ const endMonth = /^\d{1,2}$/.exec(end);
47
+ if (endMonth && !startTime.getDayOfMonth() && startTime.getMonth()) { // "1989-05/06": May to June
48
+ end = `${this.year(startTime.getYear())}-${this.pad(end)}`;
49
+ }
50
+ else {
51
+ const completedEnd = this.completeOne(end, startTime);
52
+ end = completedEnd !== end && this.isTime(end) ? this.dayAfterIfBefore(completedEnd, start) : completedEnd;
53
+ }
54
+ return start + "/" + end;
55
+ }
56
+ return this.completeOne(value, time);
57
+ }
58
+ /**
59
+ * EDTF parsing reads "1 h après le coucher" as the year 1 and ignores the rest: words must not reach it.
60
+ *
61
+ * @return If the value only holds characters of a time value (and possibly a trailing time zone).
62
+ */
63
+ isInterpretable(value) {
64
+ const withoutZone = value.replace(/\s*\(?(?:[A-Z]{2,5}|UTC[+-]?\d*)\)?\s*(\/|$)/g, "$1"); // "01:00EST/03:00EST"
65
+ return /^[\d\s\-:/~?%.,+XTZPYMWDHS]+$/.test(withoutZone);
66
+ }
67
+ /**
68
+ * @return If the value still holds a time of an unknown day ("07:30", "12 05:45"), which EDTF would read as a year.
69
+ */
70
+ isDayless(value) {
71
+ return value.split("/").some(part => /^~?\d{1,2}[: T]\d/.test(part.trim()));
72
+ }
73
+ completeOne(value, time) {
74
+ var _a;
75
+ const year = time.getYear();
76
+ const month = time.getMonth();
77
+ const day = time.getDayOfMonth();
78
+ const knownMonth = year !== undefined && month >= 1 && month <= 12;
79
+ let m;
80
+ if ((m = /^(\d{1,2})$/.exec(value)) && knownMonth && Number(m[1]) <= 31) { // "19": a day of the month
81
+ return `${this.year(year)}-${this.pad(month)}-${this.pad(m[1])}`;
82
+ }
83
+ if ((m = /^(\d{1,2})[ T](~?\d{1,2}:\d{2}(?::\d{2})?.*)$/.exec(value)) && knownMonth && Number(m[1]) <= 31) {
84
+ return `${this.year(year)}-${this.pad(month)}-${this.pad(m[1])} ${m[2]}`; // "12 05:45", "23T~01:45"
85
+ }
86
+ if ((m = /^(\d{2})-(\d{2})( .+)?$/.exec(value)) && year !== undefined && Number(m[1]) <= 12) { // "02-11 02:00"
87
+ return `${this.year(year)}-${m[1]}-${m[2]}${(_a = m[3]) !== null && _a !== void 0 ? _a : ""}`;
88
+ }
89
+ if ((m = /^(~?\d{1,2}:\d{2}(?::\d{2})?)(.*)$/.exec(value)) && knownMonth && day) { // "21:00"
90
+ return `${this.year(year)}-${this.pad(month)}-${this.pad(day)} ${m[1]}${m[2]}`;
91
+ }
92
+ return value;
93
+ }
94
+ isTime(value) {
95
+ return /^~?\d{1,2}:\d{2}/.test(value);
96
+ }
97
+ /**
98
+ * An end hour before its start hour ("23:00/03:00") is on the next day.
99
+ *
100
+ * @param end The end, completed with the day of its start ("1981-10-27 03:00").
101
+ * @param start The start ("1981-10-27 23:00").
102
+ */
103
+ dayAfterIfBefore(end, start) {
104
+ const time = /^(-?\d{4})-(\d{2})-(\d{2}) (~?)(\d{1,2}:\d{2})(.*)$/.exec(end);
105
+ const startTime = /^-?\d{4}-\d{2}-\d{2}[ T]~?(\d{1,2}:\d{2})/.exec(start);
106
+ if (time && startTime && time[5].padStart(5, "0") < startTime[1].padStart(5, "0")) {
107
+ const next = new Date(Date.UTC(Number(time[1]), Number(time[2]) - 1, Number(time[3]) + 1));
108
+ next.setUTCFullYear(Number(time[1]), Number(time[2]) - 1, Number(time[3]) + 1); // Years before 100 too
109
+ const day = `${this.year(next.getUTCFullYear())}-${this.pad(next.getUTCMonth() + 1)}-${this.pad(next.getUTCDate())}`;
110
+ end = `${day} ${time[4]}${time[5]}${time[6]}`;
111
+ }
112
+ return end;
113
+ }
114
+ year(year) {
115
+ return (year < 0 ? "-" : "") + String(Math.abs(year)).padStart(4, "0");
116
+ }
117
+ pad(n) {
118
+ return String(n).padStart(2, "0");
119
+ }
120
+ }
121
+ /**
122
+ * A trailing time zone ("PST", "(CDT)", "UTC+1") is part of the value, not free text.
123
+ */
124
+ TimeStringCompleter.timeZone = /^\(?(?:[A-Z]{1,5}|UTC[+-]?\d*)\)?$/;
125
+ /**
126
+ * A duration ("P10M", "~P1H"), or a range of durations ("P10M/12M").
127
+ */
128
+ TimeStringCompleter.duration = /^~?P\d/;
@@ -3,5 +3,10 @@ import { RR0Context } from "../../RR0Context.js";
3
3
  export declare class RelativeTimeTextBuilder {
4
4
  protected timeTextBuilder: TimeTextBuilder;
5
5
  constructor(timeTextBuilder: TimeTextBuilder);
6
- build(oldContext: RR0Context, newContext: RR0Context): string;
6
+ /**
7
+ * @param oldContext The time to render relatively to.
8
+ * @param newContext The time to render.
9
+ * @param words If a wording like "the day after" may replace the time.
10
+ */
11
+ build(oldContext: RR0Context, newContext: RR0Context, words?: boolean): string;
7
12
  }
@@ -1,14 +1,23 @@
1
+ import { TimeTextBuilder } from "./TimeTextBuilder.js";
1
2
  import { Level0Duration } from "@rr0/time";
2
3
  export class RelativeTimeTextBuilder {
3
4
  constructor(timeTextBuilder) {
4
5
  this.timeTextBuilder = timeTextBuilder;
5
6
  }
6
- build(oldContext, newContext) {
7
+ /**
8
+ * @param oldContext The time to render relatively to.
9
+ * @param newContext The time to render.
10
+ * @param words If a wording like "the day after" may replace the time.
11
+ */
12
+ build(oldContext, newContext, words = true) {
7
13
  var _a, _b, _c, _d, _e;
8
14
  let text;
9
15
  const time = newContext.time;
10
16
  const previousTime = time.equals(oldContext === null || oldContext === void 0 ? void 0 : oldContext.time) ? undefined : oldContext.time;
11
17
  let options;
18
+ if (TimeTextBuilder.isSeason(time.getMonth()) || TimeTextBuilder.isSeason(previousTime === null || previousTime === void 0 ? void 0 : previousTime.getMonth())) {
19
+ return undefined; // A season has no position within a year: always render it in full
20
+ }
12
21
  if (previousTime === null || previousTime === void 0 ? void 0 : previousTime.isDefined()) {
13
22
  options = {};
14
23
  const deltaMs = time.date.compare(previousTime.date);
@@ -30,7 +39,8 @@ export class RelativeTimeTextBuilder {
30
39
  const deltaDurationMonth = (_b = deltaDuration.months) === null || _b === void 0 ? void 0 : _b.value;
31
40
  const sameMonth = !deltaDurationMonth || deltaDurationMonth < 1;
32
41
  const noMonth = !previousMonth;
33
- const shouldSetMonth = deltaDurationMonth || (noMonth || shouldSetYear);
42
+ // Calendar fields are compared too: 22:00 to the next midnight is less than a day, but another day
43
+ const shouldSetMonth = deltaDurationMonth || (noMonth || shouldSetYear) || month !== previousMonth;
34
44
  if (shouldSetMonth) {
35
45
  deltaTime.setMonth(month);
36
46
  options.month = "long";
@@ -39,7 +49,8 @@ export class RelativeTimeTextBuilder {
39
49
  const previousDay = previousTime.getDayOfMonth();
40
50
  const dayOfMonthDelta = (_c = deltaDuration.days) === null || _c === void 0 ? void 0 : _c.value;
41
51
  const sameDay = !dayOfMonthDelta || dayOfMonthDelta < 1;
42
- const shouldSetDay = dayOfMonthDelta || (!sameDay && sameYear && sameMonth);
52
+ const shouldSetDay = dayOfMonthDelta || (!sameDay && sameYear && sameMonth) || shouldSetMonth
53
+ || dayOfMonth !== previousDay;
43
54
  const noDay = !previousDay;
44
55
  if (shouldSetDay) {
45
56
  deltaTime.setDayOfMonth(dayOfMonth);
@@ -48,7 +59,8 @@ export class RelativeTimeTextBuilder {
48
59
  }
49
60
  const hour = time.getHour();
50
61
  const hourDelta = (_d = deltaDuration.hours) === null || _d === void 0 ? void 0 : _d.value;
51
- const shouldSetHour = hourDelta || (hour && (noDay || (sameYear && sameMonth && sameDay))) || previousTime.getHour() !== time.getHour();
62
+ const shouldSetHour = hourDelta || (hour && (noDay || (sameYear && sameMonth && sameDay))) || previousTime.getHour() !== time.getHour()
63
+ || (shouldSetDay && hour !== undefined); // "12 05:45" after "03 05:00" is not just "45"
52
64
  if (shouldSetHour) {
53
65
  deltaTime.setHour(hour);
54
66
  options.hour = "2-digit";
@@ -56,8 +68,13 @@ export class RelativeTimeTextBuilder {
56
68
  const minutes = time.getMinutes();
57
69
  const minutesDelta = (_e = deltaDuration.minutes) === null || _e === void 0 ? void 0 : _e.value;
58
70
  const sameHour = hourDelta === 0;
59
- const shouldSetMinutes = minutesDelta || (minutes && (sameYear && sameMonth && sameDay && sameHour));
71
+ const shouldSetMinutes = minutesDelta || (minutes && (sameYear && sameMonth && sameDay && sameHour))
72
+ || (shouldSetHour && minutes);
60
73
  if (shouldSetMinutes) {
74
+ if (!shouldSetHour && hour !== undefined) { // Minutes alone ("19") don't tell a time, even at hour 0
75
+ deltaTime.setHour(hour);
76
+ options.hour = "2-digit";
77
+ }
61
78
  deltaTime.setMinutes(minutes);
62
79
  options.minute = "2-digit";
63
80
  }
@@ -67,7 +84,10 @@ export class RelativeTimeTextBuilder {
67
84
  // A relative wording ("the day after", "the month after"...) only makes sense against a previous time
68
85
  // that is as precise: a month page (1947-07) is not "the day before" its first entry (1947-07-01).
69
86
  const messages = newContext.messages.context.time.relative;
70
- if (dayOfMonthDelta && sameYear && sameMonth && !noDay) {
87
+ if (!words) {
88
+ // Rendered in full
89
+ }
90
+ else if (dayOfMonthDelta && sameYear && sameMonth && !noDay) {
71
91
  switch (dayOfMonthDelta) {
72
92
  case -1:
73
93
  text = messages.day.before;
@@ -1,6 +1,11 @@
1
1
  import { RR0Context } from "../../RR0Context.js";
2
2
  export declare class TimeTextBuilder {
3
3
  readonly options: Intl.DateTimeFormatOptions;
4
+ /**
5
+ * EDTF season codes (21-24) and their names, per language.
6
+ */
7
+ static readonly seasons: Record<string, Record<number, string>>;
8
+ static isSeason(month: number | undefined): boolean;
4
9
  /**
5
10
  * @param options The default date format.to use.
6
11
  */
@@ -1,4 +1,7 @@
1
1
  export class TimeTextBuilder {
2
+ static isSeason(month) {
3
+ return month >= 21 && month <= 24;
4
+ }
2
5
  /**
3
6
  * @param options The default date format.to use.
4
7
  */
@@ -9,10 +12,16 @@ export class TimeTextBuilder {
9
12
  * Build a textual representation of context's time, according to context's locale.
10
13
  */
11
14
  build(context, options = this.options) {
15
+ var _a;
12
16
  const time = context.time;
13
17
  const printOptions = {};
14
18
  const date = new Date(undefined, undefined, undefined);
15
19
  const year = time.getYear();
20
+ const season = time.getMonth();
21
+ if (TimeTextBuilder.isSeason(season)) { // "1954-23": no Date can hold it
22
+ const names = (_a = TimeTextBuilder.seasons[context.locale]) !== null && _a !== void 0 ? _a : TimeTextBuilder.seasons.en;
23
+ return year ? `${names[season]} ${year}` : names[season];
24
+ }
16
25
  if (year) {
17
26
  date.setFullYear(year);
18
27
  printOptions.year = options.year;
@@ -79,3 +88,10 @@ export class TimeTextBuilder {
79
88
  return text;
80
89
  }
81
90
  }
91
+ /**
92
+ * EDTF season codes (21-24) and their names, per language.
93
+ */
94
+ TimeTextBuilder.seasons = {
95
+ fr: { 21: "printemps", 22: "été", 23: "automne", 24: "hiver" },
96
+ en: { 21: "spring", 22: "summer", 23: "autumn", 24: "winter" }
97
+ };
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.71",
5
+ "version": "0.3.73",
6
6
  "description": "RR0 Content Management System (CMS)",
7
7
  "exports": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
@@ -34,7 +34,7 @@
34
34
  "@rr0/data": "^0.3.43",
35
35
  "@rr0/lang": "^0.1.13",
36
36
  "@rr0/place": "^0.5.4",
37
- "@rr0/time": "^0.11.2",
37
+ "@rr0/time": "^0.11.3",
38
38
  "canvas": "^3.2.1",
39
39
  "csv-parser": "^3.2.0",
40
40
  "glob": "^11.0.3",