cronli5 0.1.2 → 0.1.4

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.
@@ -9,6 +9,8 @@
9
9
  // lists render as per-hour windows).
10
10
 
11
11
  import {clockDigits, numeral} from '../../core/format.js';
12
+ import {weekdayNumbers} from '../../core/specs.js';
13
+ import {toFieldNumber} from '../../core/util.js';
12
14
  import type {Cronli5Options} from '../../types.js';
13
15
  import type {
14
16
  Field, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode,
@@ -109,16 +111,6 @@ const weekdayNames = [
109
111
  'sábado'
110
112
  ];
111
113
 
112
- // Cron token vocabulary (JAN..DEC, SUN..SAT) is part of cron syntax; map
113
- // it to Spanish names.
114
- const monthTokens: {[token: string]: number} = {
115
- JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6,
116
- JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12
117
- };
118
- const weekdayTokens: {[token: string]: number} = {
119
- SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6
120
- };
121
-
122
114
  // Ordinals for Quartz `#` weekday occurrences (1-5).
123
115
  const nthWeekdayNames =
124
116
  [null, 'primer', 'segundo', 'tercer', 'cuarto', 'quinto'];
@@ -219,6 +211,13 @@ function renderComposeSeconds(
219
211
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
220
212
  opts: Opts
221
213
  ): string {
214
+ // A wildcard or stepped second with the minute pinned to a single value
215
+ // across one or more specific hours: the seconds confine to the clock time.
216
+ if (plan.rest.kind === 'clockTimes' &&
217
+ (ir.shapes.second === 'wildcard' || ir.shapes.second === 'step')) {
218
+ return pinnedMinuteSeconds(ir, plan.rest, opts);
219
+ }
220
+
222
221
  // Seconds list + fixed clock time: nest the seconds into the clock time(s)
223
222
  // with genitive "de las HH:MM" instead of "de cada minuto"; the minute is
224
223
  // fixed so "de cada minuto" is misleading. Single seconds already fold into
@@ -256,6 +255,33 @@ function renderComposeSeconds(
256
255
  return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
257
256
  }
258
257
 
258
+ // A wildcard or stepped second under a single pinned minute and specific
259
+ // hour(s). The clock-time rest folds the minute into the hour, and on the
260
+ // 12-hour clock a pinned minute-0 drops the :00 entirely ("a las 9 de la
261
+ // mañana") — and even "a las 9" reads aloud as the whole hour, hiding the
262
+ // one-minute confinement (60 fires in :00, not 3,600 across the hour). Minute
263
+ // 0 is the one-minute window at the top of each named hour: a duration frame
264
+ // ("durante un minuto a las 9") states the confinement outright, with the hour
265
+ // as a bare hour so it cannot be heard as the whole hour. A non-zero pinned
266
+ // minute is an unambiguous clock time, so the genitive "de las 09:05" form
267
+ // reads it as the minute, never the hour.
268
+ function pinnedMinuteSeconds(
269
+ ir: IR,
270
+ rest: Extract<PlanNode, {kind: 'clockTimes'}>,
271
+ opts: Opts
272
+ ): string {
273
+ const dayTrail = leadingQualifier(ir, opts).trimEnd();
274
+ const trail = dayTrail ? ', ' + dayTrail : '';
275
+
276
+ if (+rest.times[0].minute === 0) {
277
+ return secondsLeadClause(ir, opts) + ' durante un minuto ' +
278
+ durationHourList(rest.times, opts) + trail;
279
+ }
280
+
281
+ return secondsLeadClause(ir, opts) + ' de ' +
282
+ explicitClockList(rest.times, opts) + trail;
283
+ }
284
+
259
285
  // The leading clause describing a second field relative to the minute.
260
286
  function secondsLeadClause(ir: IR, opts: Opts): string {
261
287
  const secondField = ir.pattern.second;
@@ -453,12 +479,21 @@ function renderMinuteFrequency(
453
479
  return phrase + trailingQualifier(ir, opts);
454
480
  }
455
481
 
456
- // "cada minuto de las 9:00 a las 9:29 de la mañana".
482
+ // "cada minuto de las 9:00 a las 9:29 de la mañana". A wildcard minute is the
483
+ // whole hour, so it reads as that hour itself ("cada minuto de la hora de las
484
+ // 09:00") rather than a synthesized "de las HH:00 a las HH:59" range the
485
+ // source never stated; a plain range is a real window and keeps "de … a …".
457
486
  function renderMinuteSpanInHour(
458
487
  ir: IR,
459
488
  plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
460
489
  opts: Opts
461
490
  ): string {
491
+ if (ir.pattern.minute === '*') {
492
+ return 'cada minuto de la hora ' +
493
+ fromTime(timePhrase(plan.hour, 0, null, opts)) +
494
+ trailingQualifier(ir, opts);
495
+ }
496
+
462
497
  return 'cada minuto ' +
463
498
  timeRange({hour: plan.hour, minute: plan.span[0]},
464
499
  {hour: plan.hour, minute: plan.span[1]}, opts) +
@@ -689,6 +724,82 @@ function renderClockTimes(
689
724
  return leadingQualifier(ir, opts) + groupClockTimes(phrases);
690
725
  }
691
726
 
727
+ // The genitive clock-time list for a minute-0 compose-seconds confinement:
728
+ // each time with its minute forced visible ("las 09:00"), grouped as usual,
729
+ // then reframed from "a …" to the genitive "de …" the caller prepends. So a
730
+ // pinned minute-0 reads "de las 09:00", never the bare hour.
731
+ function explicitClockList(
732
+ times: {hour: number; minute: number; second?: number | null}[],
733
+ opts: Opts
734
+ ): string {
735
+ const phrases = times.map(function clock(time) {
736
+ return atTime(explicitTimePhrase(time.hour, time.minute, opts));
737
+ });
738
+ const grouped = groupClockTimes(phrases);
739
+
740
+ // Strip the leading "a " so the caller's "de " produces the genitive form.
741
+ return grouped.startsWith('a ') ? grouped.slice(2) : grouped;
742
+ }
743
+
744
+ // The bare-hour list for a minute-0 duration confinement, keeping the "a …"
745
+ // frame the caller embeds after "durante un minuto": "a las 9",
746
+ // "a medianoche", "a las 9, 10, 11 y 12". The hour reads as a bare hour
747
+ // (no minutes), since the "durante un minuto" frame already carries the
748
+ // one-minute window — never "las 09:00", which would read as the whole hour.
749
+ function durationHourList(
750
+ times: {hour: number; minute: number; second?: number | null}[],
751
+ opts: Opts
752
+ ): string {
753
+ const phrases = times.map(function clock(time) {
754
+ return atTime(bareHourPhrase(time.hour, opts));
755
+ });
756
+
757
+ return groupClockTimes(phrases);
758
+ }
759
+
760
+ // A bare hour with its article, no minutes: "las 9" / "la 1" / "mediodía" /
761
+ // "medianoche" on the 24-hour clock, or the 12-hour day-period form
762
+ // ("las 9 de la mañana"). Used by the minute-0 duration frame, where the
763
+ // minute is already stated and the clock minute would only mislead.
764
+ function bareHourPhrase(hour: number, opts: Opts): string {
765
+ if (opts.ampm) {
766
+ return timePhrase(hour, 0, null, opts);
767
+ }
768
+
769
+ if (+hour === 0) {
770
+ return 'medianoche';
771
+ }
772
+
773
+ if (+hour === 12) {
774
+ return 'mediodía';
775
+ }
776
+
777
+ return (+hour === 1 ? 'la ' : 'las ') + hour;
778
+ }
779
+
780
+ // A clock time with its minute forced visible and the noon/midnight words
781
+ // suppressed: "las 09:00", "las 9:00 de la mañana", "las 12:00 de la tarde".
782
+ // So a pinned minute-0 confinement always shows its ":00".
783
+ function explicitTimePhrase(hour: number, minute: number, opts: Opts): string {
784
+ if (!opts.ampm) {
785
+ const article = +hour === 1 ? 'la ' : 'las ';
786
+ const suffix = opts.style.hSuffix ? ' h' : '';
787
+
788
+ return article +
789
+ clockDigits({hour, minute, second: 0},
790
+ {pad: true, sep: opts.style.sep}) + suffix;
791
+ }
792
+
793
+ const display = hour % 12 || 12;
794
+ const time = (display === 1 ? 'la ' : 'las ') +
795
+ clockDigits({hour: display, minute, second: 0}, {sep: opts.style.sep});
796
+ const period = opts.style.meridiem === 'english' ?
797
+ meridiemMark(hour) :
798
+ dayPeriod(hour, opts);
799
+
800
+ return time + ' ' + period;
801
+ }
802
+
692
803
  // Group a chronological run of "a la(s) …" clock phrases. The 12-hour clock
693
804
  // carries day periods ("de la <period>"), which group chronologically by
694
805
  // period; the 24-hour clock has none, so it falls through to article-grouping.
@@ -1689,16 +1800,13 @@ function numero(n: number, opts: Opts): string | number {
1689
1800
  return numeral(n, numeros, opts);
1690
1801
  }
1691
1802
 
1692
- // A weekday name from a number or a cron token.
1803
+ // A weekday name from a canonical number, or from a Quartz stem (`5L`,
1804
+ // `MON#2`), which the core does not number-canonicalize: resolve any name
1805
+ // via the core's index and fold the Sunday alias 7 to 0.
1693
1806
  function weekdayName(token: NameToken): string {
1694
- if (token === '7' || token === 7) {
1695
- return weekdayNames[0];
1696
- }
1807
+ const number = toFieldNumber('' + token, weekdayNumbers);
1697
1808
 
1698
- // `token` may be a numeric (string or number) field index or a cron name;
1699
- // the numeric path indexes the name array, the name path the token map.
1700
- return weekdayNames[token as number] ||
1701
- weekdayNames[weekdayTokens[token as string]];
1809
+ return weekdayNames[number === 7 ? 0 : number];
1702
1810
  }
1703
1811
 
1704
1812
  // The plural weekday form: días ending in -s are invariant ("los lunes");
@@ -1709,12 +1817,10 @@ function pluralWeekday(token: NameToken): string {
1709
1817
  return name.endsWith('s') ? name : name + 's';
1710
1818
  }
1711
1819
 
1712
- // A month name from a number or a cron token.
1820
+ // A month name from a canonical month number. The name array has a leading
1821
+ // null hole for the 1-based index.
1713
1822
  function monthName(token: NameToken): string {
1714
- // As with weekdays: a numeric index hits the name array, a cron name the
1715
- // token map. The name array has a leading null hole for the 1-based index.
1716
- return (monthNames[token as number] ||
1717
- monthNames[monthTokens[token as string]]) as string;
1823
+ return monthNames[+token] as string;
1718
1824
  }
1719
1825
 
1720
1826
  // Whether a canonical field value is an open step (`*/n` or `a/n`).
@@ -1730,7 +1836,10 @@ const es: Language<SpanishStyle> = {
1730
1836
  fallback: 'un patrón cron irreconocible',
1731
1837
  options: normalizeOptions,
1732
1838
  reboot: 'al arrancar el sistema',
1733
- sentence: (description) => 'Se ejecuta ' + description + '.'
1839
+ // A description ending in a period already carries it, so closing the
1840
+ // sentence must not double it.
1841
+ sentence: (description) =>
1842
+ 'Se ejecuta ' + description + (description.endsWith('.') ? '' : '.')
1734
1843
  };
1735
1844
 
1736
1845
  export default es;
@@ -10,6 +10,8 @@
10
10
  // case-pair construction wherever digits appear.
11
11
 
12
12
  import {clockDigits, numeral} from '../../core/format.js';
13
+ import {weekdayNumbers} from '../../core/specs.js';
14
+ import {toFieldNumber} from '../../core/util.js';
13
15
  import {resolveDialect} from './dialects.js';
14
16
  import type {
15
17
  ClockTime, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode,
@@ -159,16 +161,6 @@ const monthStems: (string | null)[] = [
159
161
  'joulu'
160
162
  ];
161
163
 
162
- // Cron token vocabulary (JAN..DEC, SUN..SAT) is part of cron syntax; map
163
- // it to field numbers.
164
- const monthTokens: {[token: string]: number} = {
165
- JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6,
166
- JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12
167
- };
168
- const weekdayTokens: {[token: string]: number} = {
169
- SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6
170
- };
171
-
172
164
  // Unit form tables for the anchored-minute/second constructions.
173
165
  // `mark` is the frequency for the "N minuutin kohdalla" ("at the
174
166
  // N-minute mark") form; `anchor` is the possessive for the elative
@@ -349,9 +341,42 @@ function renderComposeSeconds(
349
341
  hourClause + trailingQualifier(ir, opts);
350
342
  }
351
343
 
344
+ // A sub-minute second with the minute pinned to 0 and a specific hour: the
345
+ // clock-time rest would read "klo 9", dropping the pinned :00 and so the
346
+ // one-minute confinement (60 fires in :00, not 3,600 across the hour). Bind
347
+ // the seconds to the explicit clock minute with the "minuutin HH.00 aikana"
348
+ // frame (an "of"/during form, never a range) and trail the day qualifier
349
+ // ("joka sekunti minuutin 9.00 aikana, joka päivä").
350
+ if (plan.rest.kind === 'clockTimes' &&
351
+ plan.rest.times.every((time) => +time.minute === 0)) {
352
+ return composeMinuteZero(ir, plan.rest, opts);
353
+ }
354
+
352
355
  return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
353
356
  }
354
357
 
358
+ // The minute-0 confinement: bind the seconds to the explicit clock minute(s)
359
+ // in the "minuutin/minuuttien HH.00 aikana" frame (an "of"/during form, never
360
+ // a range — a range would round-trip back to the whole hour) and trail the day
361
+ // qualifier ("joka sekunti minuutin 9.00 aikana, joka päivä").
362
+ function composeMinuteZero(
363
+ ir: IR,
364
+ rest: Extract<PlanNode, {kind: 'clockTimes'}>,
365
+ opts: NormalizedOptions
366
+ ): string {
367
+ const clocks = rest.times.map(function clock(time): string {
368
+ return clockDigits({hour: time.hour, minute: time.minute},
369
+ {sep: opts.style.sep});
370
+ });
371
+ const frame = clocks.length === 1 ?
372
+ 'minuutin ' + clocks[0] :
373
+ 'minuuttien ' + joinList(clocks);
374
+ const dayTrail = leadingQualifier(ir, opts).trimEnd();
375
+
376
+ return secondsLeadClause(ir, opts) + ' ' + frame + ' aikana' +
377
+ (dayTrail ? ', ' + dayTrail : '');
378
+ }
379
+
355
380
  // The leading clause describing a second field relative to the minute.
356
381
  function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
357
382
  const secondField = ir.pattern.second;
@@ -542,12 +567,20 @@ function renderMinuteFrequency(
542
567
  return phrase + trailingQualifier(ir, opts);
543
568
  }
544
569
 
545
- // "joka minuutti klo 9.00–9.59".
570
+ // "joka minuutti klo 9.00–9.59". A wildcard minute is the whole hour, so it
571
+ // reads as that hour itself ("joka minuutti kello 9 aikana") rather than a
572
+ // synthesized "klo 9.00–9.59" range the source never stated; a plain range is
573
+ // a real window and keeps the dash form.
546
574
  function renderMinuteSpanInHour(
547
575
  ir: IR,
548
576
  plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
549
577
  opts: NormalizedOptions
550
578
  ): string {
579
+ if (ir.pattern.minute === '*') {
580
+ return 'joka minuutti kello ' + plan.hour + ' aikana' +
581
+ trailingQualifier(ir, opts);
582
+ }
583
+
551
584
  return 'joka minuutti ' +
552
585
  kloRange({hour: plan.hour, minute: plan.span[0]},
553
586
  {hour: plan.hour, minute: plan.span[1]}, opts) +
@@ -1339,18 +1372,16 @@ function quartzWeekdayPhrase(weekdayField: string): string | undefined {
1339
1372
  }
1340
1373
  }
1341
1374
 
1342
- // Resolve a weekday token or number to its table index.
1375
+ // Resolve a weekday to its table index. Weekday-field segments are already
1376
+ // canonical numbers; a Quartz stem (`5L`, `MON#2`) is not, so resolve any
1377
+ // name via the core's index (with the Sunday alias 7 folding to 0).
1343
1378
  function weekdayNumber(token: string | number): number {
1344
- if (token in weekdayTokens) {
1345
- return weekdayTokens[token];
1346
- }
1347
-
1348
- return +token % 7;
1379
+ return toFieldNumber('' + token, weekdayNumbers) % 7;
1349
1380
  }
1350
1381
 
1351
- // Resolve a month token or number to its table index.
1382
+ // Resolve a canonical month number to its table index.
1352
1383
  function monthNumber(token: string | number): number {
1353
- return monthTokens[token] || +token;
1384
+ return +token;
1354
1385
  }
1355
1386
 
1356
1387
  // --- Years. ---
@@ -1492,7 +1523,10 @@ const fi: Language = {
1492
1523
  fallback: 'tunnistamaton cron-lauseke',
1493
1524
  options: normalizeOptions,
1494
1525
  reboot: 'järjestelmän käynnistyessä',
1495
- sentence: (description) => 'Suoritetaan ' + description + '.'
1526
+ // A description ending in a period already carries it, so closing the
1527
+ // sentence must not double it.
1528
+ sentence: (description) =>
1529
+ 'Suoritetaan ' + description + (description.endsWith('.') ? '' : '.')
1496
1530
  };
1497
1531
 
1498
1532
  export default fi;
@@ -226,7 +226,13 @@ function renderMinuteFrequency(ir: IR, plan: PlanNode): string {
226
226
  const {hours} = plan as Extract<PlanNode, {kind: 'minuteFrequency'}>;
227
227
 
228
228
  if (hours.kind === 'step') {
229
- return cadence(stepSegment(ir, 'hour').interval, UNITS.hour) + base;
229
+ const hourStep = stepSegment(ir, 'hour');
230
+
231
+ // "每N小时" is only faithful from midnight; an offset step (2/6 fires at
232
+ // 2,8,14,20) enumerates its hours instead.
233
+ return hourStep.startToken === '*' ?
234
+ cadence(hourStep.interval, UNITS.hour) + base :
235
+ '在' + hourList(ir) + ',' + base;
230
236
  }
231
237
 
232
238
  if (hours.kind === 'single' ||
@@ -247,10 +253,16 @@ function renderMinuteFrequency(ir: IR, plan: PlanNode): string {
247
253
  return base;
248
254
  }
249
255
 
250
- // A minute span within a single hour: "在9点至9点58分之间,每分钟".
256
+ // A minute span within a single hour. A wildcard minute reads as that hour
257
+ // itself — "凌晨0点的每一分钟" — not a synthesized "在H点至H点59分之间" range the
258
+ // source never stated; a partial minute span keeps the named range.
251
259
  function renderMinuteSpanInHour(ir: IR, plan: PlanNode): string {
252
260
  const span = plan as Extract<PlanNode, {kind: 'minuteSpanInHour'}>;
253
261
 
262
+ if (ir.pattern.minute === '*') {
263
+ return hourWord(span.hour) + '的每一分钟';
264
+ }
265
+
254
266
  return '在' + hourWord(span.hour) + '至' + span.hour + '点' +
255
267
  span.span[1] + '分之间,每分钟';
256
268
  }
@@ -271,15 +283,31 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanNode): string {
271
283
  // A minute clause across a stepped hour field. A wildcard minute reads "每2小时
272
284
  // 内,每分钟"; a ranged minute names it: "每2小时,每小时0至30分,每分钟".
273
285
  function renderMinuteSpanAcrossHourStep(ir: IR, plan: PlanNode): string {
274
- const cad = cadence(stepSegment(ir, 'hour').interval, UNITS.hour);
286
+ const hourStep = stepSegment(ir, 'hour');
275
287
  const {form} = plan as Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>;
288
+ const minuteTail = form === 'wildcard' ?
289
+ '每分钟' :
290
+ '每小时' + valueList(fieldSegments(ir, 'minute'), '分') + ',每分钟';
276
291
 
277
- if (form === 'wildcard') {
278
- return cad + '内,每分钟';
292
+ // An offset stride (2/6 fires at 2,8,14,20) enumerates its hours like a
293
+ // discrete list; "每N小时" is faithful only from midnight.
294
+ if (hourStep.startToken !== '*') {
295
+ return form === 'wildcard' ?
296
+ '在' + hourList(ir) + ',' + minuteTail :
297
+ hourList(ir) + ',' + minuteTail;
279
298
  }
280
299
 
281
- return cad + ',每小时' + valueList(fieldSegments(ir, 'minute'), '分') +
282
- ',每分钟';
300
+ // A step-2 hour from midnight IS exactly the even hours; name them so, rather
301
+ // than the vague "每2小时内" that reads as an interval. Other strides keep it.
302
+ if (hourStep.interval === 2 && form === 'wildcard') {
303
+ return '在偶数小时,' + minuteTail;
304
+ }
305
+
306
+ const cad = cadence(hourStep.interval, UNITS.hour);
307
+
308
+ return form === 'wildcard' ?
309
+ cad + '内,' + minuteTail :
310
+ cad + ',' + minuteTail;
283
311
  }
284
312
 
285
313
  // Discrete clock times: "9点", "9点和17点".
@@ -422,11 +450,30 @@ function isHourCadence(ir: IR): boolean {
422
450
  function composeSecondsOnHour(ir: IR, plan: PlanNode, opts: Opts): string {
423
451
  const sec = secondClause(ir);
424
452
  const {rest} = plan as Extract<PlanNode, {kind: 'composeSeconds'}>;
425
- const restText = render(ir, rest, opts);
426
453
 
454
+ // The minute is pinned to 0 under a specific hour: a bare clock word ("9点")
455
+ // would hide the :00 and leave the second dangling ("…9点每秒"), reading as
456
+ // the whole hour. Fuse the seconds with the explicit clock minute ("9点0分
457
+ // 的每一秒"), so the one-minute confinement (60 fires in :00, not 3,600
458
+ // across the hour) stays visible. The daily frame leads with 每天; a weekday
459
+ // or date qualifier is added by describe().
427
460
  if ((rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes') &&
428
- isDaily(ir)) {
429
- return '每天' + restText + sec;
461
+ ir.pattern.minute === '0') {
462
+ const clocks = hourFires(ir).map(function clock(hour): string {
463
+ return hourWord(hour) + '0分';
464
+ });
465
+ const tail = sec === '每秒' ? '的每一秒' : '的' + sec;
466
+ const core = joinAnd(clocks) + tail;
467
+
468
+ return isDaily(ir) ? '每天' + core : core;
469
+ }
470
+
471
+ const restText = render(ir, rest, opts);
472
+
473
+ if (rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes') {
474
+ if (isDaily(ir)) {
475
+ return '每天' + restText + sec;
476
+ }
430
477
  }
431
478
 
432
479
  // A stated minute (e.g. minute 0 under a sub-minute second) takes the same
@@ -466,6 +513,14 @@ function composeSecondsListed(ir: IR): string {
466
513
  const sec = secondClause(ir);
467
514
  const minutes = '每小时' + valueList(fieldSegments(ir, 'minute'), '分');
468
515
 
516
+ // A single restricted hour with an every-second cadence fuses the clock time
517
+ // with its minutes — "凌晨0点5、20、35、50分的每一秒" — rather than the "每小时"
518
+ // that falsely implies every hour. A non-wildcard second keeps the list form.
519
+ if (ir.shapes.hour === 'single' && sec === '每秒') {
520
+ return hourWord(hourFires(ir)[0]) +
521
+ valueList(fieldSegments(ir, 'minute'), '分') + '的每一秒';
522
+ }
523
+
469
524
  if (ir.shapes.hour === 'wildcard') {
470
525
  return minutes + ',' + sec;
471
526
  }
@@ -479,12 +534,28 @@ function composeSecondsListed(ir: IR): string {
479
534
  }
480
535
 
481
536
  // Seconds composed with the minute/hour structure, dispatched on the minute.
537
+ // A single minute over a composed clock-time rest (the core already joined the
538
+ // lone hour and minute into "N点M分") keeps that composition, attaching the
539
+ // second to it rather than splitting the minute back out into the "每小时N分"
540
+ // list path; a minute list stays on that list path so each fire is named.
482
541
  function renderComposeSeconds(ir: IR, plan: PlanNode, opts: Opts): string {
483
- if (ir.pattern.minute === '0') {
542
+ const {rest} = plan as Extract<PlanNode, {kind: 'composeSeconds'}>;
543
+ const composedClock =
544
+ rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes';
545
+
546
+ if (ir.pattern.minute === '0' ||
547
+ composedClock && ir.shapes.minute === 'single') {
484
548
  return composeSecondsOnHour(ir, plan, opts);
485
549
  }
486
550
 
487
- if (ir.pattern.minute === '*' || ir.shapes.minute === 'step') {
551
+ // "每N分钟" is faithful only for a wildcard or top-of-hour step; an offset
552
+ // step (5/15 fires at :05,:20,…) takes the enumerated list path so its start
553
+ // is named, never dropped.
554
+ const minuteCadence = ir.pattern.minute === '*' ||
555
+ ir.shapes.minute === 'step' &&
556
+ stepSegment(ir, 'minute').startToken === '*';
557
+
558
+ if (minuteCadence) {
488
559
  return composeSecondsCadence(ir);
489
560
  }
490
561