cronli5 0.8.0 → 0.8.3

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.
@@ -127,6 +127,16 @@ const weekdayNames = [
127
127
  const nthWeekdayMasculine =
128
128
  [null, 'premier', 'deuxième', 'troisième', 'quatrième', 'cinquième'];
129
129
 
130
+ // French ordinals (gender-neutral "-ième") for a stepped-minute cadence under a
131
+ // seconds lead ("à la sixième minute"). The interval-2 step keeps its own
132
+ // idiom and never reaches here; a lookup miss falls back to the cardinal-with-
133
+ // preposition form, which still confines (see `minuteStepConfinement`).
134
+ const stepOrdinals: Record<number, string> = {
135
+ 3: 'troisième', 4: 'quatrième', 5: 'cinquième', 6: 'sixième',
136
+ 7: 'septième', 8: 'huitième', 9: 'neuvième', 10: 'dixième',
137
+ 12: 'douzième', 15: 'quinzième', 20: 'vingtième', 30: 'trentième'
138
+ };
139
+
130
140
  // Normalize raw user options.
131
141
  function normalizeOptions(options?: Cronli5Options): Opts {
132
142
  options = options || {};
@@ -279,6 +289,67 @@ function isPinnedMinuteSeconds(
279
289
  schedule.shapes.second === 'step');
280
290
  }
281
291
 
292
+ // The minute field's step stride for the confinement frame, or null when the
293
+ // minute is not a stepped cadence. A `step`-shaped field reads its segment; a
294
+ // `list`-shaped field the core enumerated from a uneven step (`2/7` → 2,9,…,58)
295
+ // recovers the progression from its values.
296
+ function minuteStride(
297
+ schedule: Schedule
298
+ ): {start: number; interval: number; last: number} | null {
299
+ if (schedule.shapes.minute === 'step') {
300
+ const segment = stepSegment(schedule, 'minute');
301
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
302
+
303
+ return {interval: segment.interval, last:
304
+ segment.fires[segment.fires.length - 1], start};
305
+ }
306
+
307
+ const values = singleValues(segmentsOf(schedule, 'minute'));
308
+
309
+ return values && arithmeticStep(values);
310
+ }
311
+
312
+ // A stepped minute under a wildcard/stepped second and wildcard hour: bind the
313
+ // second cadence to the minute cadence as a CONFINEMENT ("chaque seconde à la
314
+ // sixième minute à partir de la minute 4 de chaque heure"), never the comma
315
+ // juxtaposition that reads as two independent cadences. The cadence is ORDINAL
316
+ // ("à la sixième minute") — the cardinal "toutes les six minutes" is what fuels
317
+ // the misread — and the start/bound mirror the standalone minute cadence.
318
+ function minuteStepConfinement(
319
+ schedule: Schedule,
320
+ stride: {start: number; interval: number; last: number},
321
+ opts: Opts
322
+ ): string {
323
+ const ordinal = stepOrdinals[stride.interval];
324
+ const head = ordinal ?
325
+ 'à la ' + ordinal + ' minute' :
326
+ 'à la minute toutes les ' + numero(stride.interval, opts);
327
+
328
+ const tail = chooseStride({...stride, cycle: 60}, {
329
+ bare: () => '',
330
+ offset: () => ' à partir de la minute ' + stride.start,
331
+ bounded: () => ' de la minute ' + stride.start + ' à ' + stride.last
332
+ });
333
+
334
+ return secondsLeadClause(schedule, opts) + ' ' + head + tail +
335
+ ' de chaque heure' + trailingQualifier(schedule, opts);
336
+ }
337
+
338
+ // Whether a stepped minute fills a wildcard hour under a wildcard/stepped
339
+ // second — the shape the confinement frame above handles.
340
+ function isSteppedMinuteSeconds(
341
+ schedule: Schedule,
342
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
343
+ ): boolean {
344
+ return (plan.rest.kind === 'minuteFrequency' ||
345
+ plan.rest.kind === 'multipleMinutes') &&
346
+ (schedule.shapes.second === 'wildcard' ||
347
+ schedule.shapes.second === 'step') &&
348
+ schedule.shapes.hour === 'wildcard' &&
349
+ schedule.pattern.minute !== '*/2' &&
350
+ minuteStride(schedule) !== null;
351
+ }
352
+
282
353
  function renderComposeSeconds(
283
354
  schedule: Schedule,
284
355
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
@@ -322,6 +393,14 @@ function renderComposeSeconds(
322
393
  return dayFrame + ', ' + window + ', ' + cadence;
323
394
  }
324
395
 
396
+ // A stepped minute under a wildcard/stepped second + wildcard hour confines
397
+ // the second cadence to the ordinal minute cadence ("chaque seconde à la
398
+ // sixième minute à partir de la minute 4 de chaque heure"), never the comma
399
+ // juxtaposition that reads as two independent cadences.
400
+ if (isSteppedMinuteSeconds(schedule, plan)) {
401
+ return minuteStepConfinement(schedule, minuteStride(schedule)!, opts);
402
+ }
403
+
325
404
  // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
326
405
  // cadences that read as contradictory ("chaque seconde, toutes les deux
327
406
  // minutes"). Bind them with the genitive "de" ("chaque seconde de chaque
@@ -476,6 +555,16 @@ function minutesList(schedule: Schedule, opts: Opts): string {
476
555
  joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' de chaque heure';
477
556
  }
478
557
 
558
+ // Strip the generic "de chaque heure" anchor from a minute-cadence lead. Under
559
+ // an hour STEP the hour clause is the sole hour authority, so the cadence must
560
+ // not also assert "de chaque heure" — alongside a stepped hour it reads as a
561
+ // conflicting every-hour scope ("de chaque heure, toutes les quatre heures").
562
+ // An hour WINDOW and an unrestricted hour keep the anchor (the window already
563
+ // names the hours; an open hour has no other hour statement).
564
+ function withoutHourAnchor(lead: string): string {
565
+ return lead.replace(/ de chaque heure$/, '');
566
+ }
567
+
479
568
  // "chaque minute de 0 à 30". The standalone renderer adds "de chaque heure";
480
569
  // when an hour qualifier follows ("..., à 9 h", "..., toutes les deux heures")
481
570
  // it would contradict, so it is not baked in here.
@@ -550,8 +639,10 @@ function renderMinuteFrequency(
550
639
  }
551
640
  else if (plan.hours.kind === 'step') {
552
641
  // A clean stride is a confinement ("les heures paires", or the active-hour
553
- // list), never a juxtaposed cadence ("toutes les deux heures").
554
- phrase += ', ' + stepHourSpan(stepSegment(schedule, 'hour'), opts);
642
+ // list), never a juxtaposed cadence ("toutes les deux heures"). The hour
643
+ // step scopes the hours, so an offset cadence drops "de chaque heure".
644
+ phrase = withoutHourAnchor(phrase) + ', ' +
645
+ stepHourSpan(stepSegment(schedule, 'hour'), opts);
555
646
  }
556
647
 
557
648
  return phrase + trailingQualifier(schedule, opts);
@@ -635,10 +726,10 @@ function renderMinuteSpanAcrossHourStep(
635
726
 
636
727
  // A minute list keeps the same cadence clause as the range; only its lead
637
728
  // differs ("aux minutes 5 et 30 de chaque heure" vs "chaque minute de 0 à
638
- // 30").
639
- const lead = plan.form === 'list' ?
729
+ // 30"). The hour step scopes the hours, so the lead drops "de chaque heure".
730
+ const lead = withoutHourAnchor(plan.form === 'list' ?
640
731
  minutesList(schedule, opts) :
641
- minuteRangeLead(schedule.pattern.minute);
732
+ minuteRangeLead(schedule.pattern.minute));
642
733
 
643
734
  return lead + ', ' +
644
735
  (cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
@@ -991,10 +1082,13 @@ function renderCompactClockTimes(
991
1082
  }
992
1083
 
993
1084
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
994
- // of clock-time columns.
1085
+ // of clock-time columns. That hour step is the sole hour authority, so the
1086
+ // minute lead drops its generic "de chaque heure" (an every-hour scope that
1087
+ // would conflict with the step); the clock-time branch keeps it, naming
1088
+ // specific hours rather than a step.
995
1089
  const cadence = unevenHourCadence(schedule, opts);
996
1090
  const phrase = cadence ?
997
- minutesList(schedule, opts) + ', ' + cadence +
1091
+ withoutHourAnchor(minutesList(schedule, opts)) + ', ' + cadence +
998
1092
  trailingQualifier(schedule, opts) :
999
1093
  minutesList(schedule, opts) + ', ' +
1000
1094
  hourContextTimes(schedule, opts) + trailingQualifier(schedule, opts);
@@ -144,6 +144,16 @@ const nthWeekdayMasculine =
144
144
  const nthWeekdayFeminine =
145
145
  [null, 'primeira', 'segunda', 'terceira', 'quarta', 'quinta'];
146
146
 
147
+ // Portuguese ordinals (masculine) for a stepped-minute cadence under a seconds
148
+ // lead ("a cada segundo no sexto minuto"). The interval-2 step keeps its own
149
+ // idiom and never reaches here, so the colliding "segundo" is unused; a lookup
150
+ // miss falls back to the cardinal-with-"no" form, which still confines.
151
+ const stepOrdinals: Record<number, string> = {
152
+ 3: 'terceiro', 4: 'quarto', 5: 'quinto', 6: 'sexto', 7: 'sétimo',
153
+ 8: 'oitavo', 9: 'nono', 10: 'décimo', 12: 'décimo segundo',
154
+ 15: 'décimo quinto', 20: 'vigésimo', 30: 'trigésimo'
155
+ };
156
+
147
157
  // --- Contractions (the principal es->pt divergence). ---
148
158
  //
149
159
  // Portuguese fuses a preposition with the following article wherever es emitted
@@ -413,6 +423,67 @@ function isPinnedMinuteSeconds(
413
423
  schedule.shapes.second === 'step');
414
424
  }
415
425
 
426
+ // The minute field's step stride for the confinement frame, or null when the
427
+ // minute is not a stepped cadence. A `step`-shaped field reads its segment; a
428
+ // `list`-shaped field the core enumerated from a uneven step (`2/7` → 2,9,…,58)
429
+ // recovers the progression from its values.
430
+ function minuteStride(
431
+ schedule: Schedule
432
+ ): {start: number; interval: number; last: number} | null {
433
+ if (schedule.shapes.minute === 'step') {
434
+ const segment = stepSegment(schedule, 'minute');
435
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
436
+
437
+ return {interval: segment.interval, last:
438
+ segment.fires[segment.fires.length - 1], start};
439
+ }
440
+
441
+ const values = singleValues(segmentsOf(schedule, 'minute'));
442
+
443
+ return values && arithmeticStep(values);
444
+ }
445
+
446
+ // A stepped minute under a wildcard/stepped second and wildcard hour: bind the
447
+ // second cadence to the minute cadence as a CONFINEMENT ("a cada segundo no
448
+ // sexto minuto a partir do minuto 4 de cada hora"), never the comma
449
+ // juxtaposition that reads as two independent cadences. The cadence is ORDINAL
450
+ // ("no sexto minuto") — the cardinal "a cada seis minutos" is what fuels the
451
+ // misread — and the start/bound mirror the standalone minute cadence.
452
+ function minuteStepConfinement(
453
+ schedule: Schedule,
454
+ stride: {start: number; interval: number; last: number},
455
+ opts: Opts
456
+ ): string {
457
+ const ordinal = stepOrdinals[stride.interval];
458
+ const head = ordinal ?
459
+ 'no ' + ordinal + ' minuto' :
460
+ 'a cada ' + numero(stride.interval, opts) + ' minutos';
461
+
462
+ const tail = chooseStride({...stride, cycle: 60}, {
463
+ bare: () => '',
464
+ offset: () => ' a partir do minuto ' + stride.start,
465
+ bounded: () => ' do minuto ' + stride.start + ' ao ' + stride.last
466
+ });
467
+
468
+ return secondsLeadClause(schedule, opts) + ' ' + head + tail +
469
+ ' de cada hora' + trailingQualifier(schedule, opts);
470
+ }
471
+
472
+ // Whether a stepped minute fills a wildcard hour under a wildcard/stepped
473
+ // second — the shape the confinement frame above handles.
474
+ function isSteppedMinuteSeconds(
475
+ schedule: Schedule,
476
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
477
+ ): boolean {
478
+ return (plan.rest.kind === 'minuteFrequency' ||
479
+ plan.rest.kind === 'multipleMinutes') &&
480
+ (schedule.shapes.second === 'wildcard' ||
481
+ schedule.shapes.second === 'step') &&
482
+ schedule.shapes.hour === 'wildcard' &&
483
+ schedule.pattern.minute !== '*/2' &&
484
+ minuteStride(schedule) !== null;
485
+ }
486
+
416
487
  function renderComposeSeconds(
417
488
  schedule: Schedule,
418
489
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
@@ -456,6 +527,14 @@ function renderComposeSeconds(
456
527
  return dayFrame + ', ' + window + ', ' + cadence;
457
528
  }
458
529
 
530
+ // A stepped minute under a wildcard/stepped second + wildcard hour confines
531
+ // the second cadence to the ordinal minute cadence ("a cada segundo no sexto
532
+ // minuto a partir do minuto 4 de cada hora"), never the comma juxtaposition
533
+ // that reads as two independent cadences.
534
+ if (isSteppedMinuteSeconds(schedule, plan)) {
535
+ return minuteStepConfinement(schedule, minuteStride(schedule)!, opts);
536
+ }
537
+
459
538
  // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
460
539
  // cadences that read as contradictory ("a cada segundo, a cada dois
461
540
  // minutos"). Bind them with the genitive "de" ("a cada segundo de cada dois
@@ -616,6 +695,16 @@ function minutesList(schedule: Schedule, opts: Opts): string {
616
695
  joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' de cada hora';
617
696
  }
618
697
 
698
+ // Strip the generic "de cada hora" anchor from a minute-cadence lead. Under an
699
+ // hour STEP the hour clause is the sole hour authority, so the cadence must not
700
+ // also assert "de cada hora" — alongside a stepped hour it reads as a
701
+ // conflicting every-hour scope ("de cada hora, a cada quatro horas"). An hour
702
+ // WINDOW and an unrestricted hour keep the anchor (the window already names the
703
+ // hours; an open hour has no other hour statement).
704
+ function withoutHourAnchor(lead: string): string {
705
+ return lead.replace(/ de cada hora$/, '');
706
+ }
707
+
619
708
  // "a cada minuto do 0 ao 30". The standalone renderer adds "de cada hora";
620
709
  // when an hour qualifier follows ("..., às 09:00", "..., a cada duas horas")
621
710
  // it would contradict, so it is not baked in here.
@@ -746,8 +835,10 @@ function renderMinuteFrequency(
746
835
  }
747
836
  else if (plan.hours.kind === 'step') {
748
837
  // A clean stride is a confinement ("as horas pares", or the active-hour
749
- // list), never a juxtaposed cadence ("a cada duas horas").
750
- phrase += ', ' + stepHourSpan(stepSegment(schedule, 'hour'), opts);
838
+ // list), never a juxtaposed cadence ("a cada duas horas"). The hour step
839
+ // scopes the hours, so an offset cadence drops "de cada hora".
840
+ phrase = withoutHourAnchor(phrase) + ', ' +
841
+ stepHourSpan(stepSegment(schedule, 'hour'), opts);
751
842
  }
752
843
 
753
844
  return phrase + trailingQualifier(schedule, opts);
@@ -832,9 +923,10 @@ function renderMinuteSpanAcrossHourStep(
832
923
 
833
924
  // A minute list keeps the same cadence clause as the range; only its lead
834
925
  // differs ("nos minutos 5 e 30 de cada hora" vs "a cada minuto do 0 ao 30").
835
- const lead = plan.form === 'list' ?
926
+ // The hour step scopes the hours, so the lead drops "de cada hora".
927
+ const lead = withoutHourAnchor(plan.form === 'list' ?
836
928
  minutesList(schedule, opts) :
837
- minuteRangeLead(schedule.pattern.minute);
929
+ minuteRangeLead(schedule.pattern.minute));
838
930
 
839
931
  return lead + ', ' +
840
932
  (cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
@@ -1423,10 +1515,13 @@ function renderCompactClockTimes(
1423
1515
  }
1424
1516
 
1425
1517
  // A uneven hour stride reads as a cadence after the minute lead, not a wall
1426
- // of clock-time columns.
1518
+ // of clock-time columns. That hour step is the sole hour authority, so the
1519
+ // minute lead drops its generic "de cada hora" (an every-hour scope that
1520
+ // would conflict with the step); the clock-time branch keeps it, naming
1521
+ // specific hours rather than a step.
1427
1522
  const cadence = unevenHourCadence(schedule, opts);
1428
1523
  const phrase = cadence ?
1429
- minutesList(schedule, opts) + ', ' + cadence +
1524
+ withoutHourAnchor(minutesList(schedule, opts)) + ', ' + cadence +
1430
1525
  trailingQualifier(schedule, opts) :
1431
1526
  minutesList(schedule, opts) + ', ' +
1432
1527
  hourContextTimes(schedule, opts) + trailingQualifier(schedule, opts);
@@ -327,6 +327,16 @@ function renderMinutePast(schedule: Schedule): string {
327
327
  return minuteHourClause(schedule);
328
328
  }
329
329
 
330
+ // Strip the generic "每小时" (every-hour) anchor that leads a minute clause.
331
+ // Under an hour STEP the hour cadence is the sole hour authority, so the minute
332
+ // clause must not also assert "每小时" — alongside a stepped hour ("每4小时…每小
333
+ // 时…") it reads as a conflicting every-hour scope. An hour WINDOW and an
334
+ // unrestricted hour keep "每小时" (the window already names the hours; an open
335
+ // hour has no other hour statement).
336
+ function withoutHourAnchor(clause: string): string {
337
+ return clause.replace(/^每小时/, '');
338
+ }
339
+
330
340
  // One hour segment as clock words by its form: a range is a span ("9点至20点"),
331
341
  // a single is one clock word ("22点"), a step keeps its fires enumerated as
332
342
  // clock words ("9点、11点、13点"). A range stated as a list element should read
@@ -383,7 +393,14 @@ function renderMinuteFrequency(schedule: Schedule, plan: PlanNode): string {
383
393
  const hourCad = unevenHourCadence(schedule);
384
394
 
385
395
  if (hourCad !== null) {
386
- return hourCad + (hourCad.indexOf('至') === -1 ? '' : ',') + base;
396
+ // An hour STEP is the sole hour authority, so an offset minute cadence
397
+ // drops its leading "每小时" ("每4小时从5分起每10分钟"); a discrete hour
398
+ // list (during) keeps it. Only the step path reaches a non-null cadence
399
+ // here — an irregular list falls through to the enumerated frame below.
400
+ const minuteBase = hours.kind === 'step' ?
401
+ withoutHourAnchor(base) : base;
402
+
403
+ return hourCad + (hourCad.indexOf('至') === -1 ? '' : ',') + minuteBase;
387
404
  }
388
405
  }
389
406
 
@@ -445,15 +462,17 @@ function renderMinuteSpanAcrossHourStep(
445
462
  const {form} = plan as Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>;
446
463
 
447
464
  // A minute list reads as the hour cadence plus the minute list ("每2小时,
448
- // 每小时0、25、50分"; offset "从1点起每2小时,每小时5分和30分"), the same compaction
449
- // the wildcard/range minute already uses, rather than the enumerated hours.
465
+ // 0、25、50分"; offset "从1点起每2小时,5分和30分"), the same compaction the
466
+ // wildcard/range minute already uses, rather than the enumerated hours. The
467
+ // hour cadence scopes the hours, so the minute clause drops its "每小时".
450
468
  if (form === 'list') {
451
- return hourCadencePhrase(schedule) + ',' + renderMinutePast(schedule);
469
+ return hourCadencePhrase(schedule) + ',' +
470
+ withoutHourAnchor(renderMinutePast(schedule));
452
471
  }
453
472
 
454
473
  const minuteTail = form === 'wildcard' ?
455
474
  '每分钟' :
456
- minuteHourClause(schedule) + ',每分钟';
475
+ withoutHourAnchor(minuteHourClause(schedule)) + ',每分钟';
457
476
 
458
477
  // An offset or non-tiling stride (2/6 fires at 2,8,14,20) reads as its
459
478
  // cadence ("从2点起每6小时"). A wildcard minute hangs off it with a comma; a
@@ -518,9 +537,13 @@ function renderCompactClockTimes(schedule: Schedule, plan: PlanNode): string {
518
537
  if (!compact.fold) {
519
538
  const hourCad = unevenHourCadence(schedule);
520
539
 
540
+ // A bounded/uneven hour step leads as the cadence and is the sole hour
541
+ // authority, so the minute clause drops its generic "每小时" every-hour
542
+ // scope; an enumerated hour list (hourCad null) names specific hours and
543
+ // keeps the anchor.
521
544
  return hourCad === null ?
522
545
  minuteHourClause(schedule) + ',在' + hourList(schedule) + tail :
523
- hourCad + ',' + minuteHourClause(schedule) + tail;
546
+ hourCad + ',' + withoutHourAnchor(minuteHourClause(schedule)) + tail;
524
547
  }
525
548
 
526
549
  // A single pinned minute past 0 leads with its clause; a pinned 0 folds into
@@ -870,6 +893,15 @@ function composeSecondsOnHour(
870
893
  return composeMinuteZeroClocks(schedule, sec);
871
894
  }
872
895
 
896
+ // A single fixed (non-zero) minute under enumerated clock times fuses the
897
+ // seconds onto the composed clock time the same way ("0点2分的每一秒").
898
+ const fusedSingleMinute =
899
+ composeSingleMinuteClocks(schedule, rest, sec, opts);
900
+
901
+ if (fusedSingleMinute !== null) {
902
+ return fusedSingleMinute;
903
+ }
904
+
873
905
  const restText = render(schedule, rest, opts);
874
906
  const secTail = clockRestCarriesSecond(rest) ? '' : sec;
875
907
 
@@ -877,15 +909,42 @@ function composeSecondsOnHour(
877
909
  return '每天' + restText + secTail;
878
910
  }
879
911
 
880
- // A stated minute (e.g. minute 0 under a sub-minute second) takes the same
881
- // "" connector the listed-minute path uses.
912
+ // A stated single minute (minute 0 under an open hour) confines the second
913
+ // beneath it with "" when the second is a cadence ("每小时0分的每一秒"), the
914
+ // same fusion the other pinned-minute paths use; the bare comma ("每小时0分,
915
+ // 每秒") reads as two independent cadences. A single/list/range second is a
916
+ // clock-point, not a cadence, so it keeps the "," connector.
882
917
  if (rest.kind === 'singleMinute') {
883
- return restText + ',' + sec;
918
+ return secondIsCadence(schedule) ?
919
+ restText + confinedSecondTail(sec) : restText + ',' + sec;
884
920
  }
885
921
 
886
922
  return restText + secTail;
887
923
  }
888
924
 
925
+ // A single fixed (non-zero) minute under enumerated clock times: each clock
926
+ // point already names the minute ("0点2分", "9点5分和17点5分"), so bind the
927
+ // seconds to it with "的" — the same fusion the minute-0 ("0分的每一秒") and
928
+ // minute-step ("5、20…分的每一秒") cases use — rather than leaving a bare
929
+ // trailing "每秒" that floats as a second, unlinked adverbial. A single second
930
+ // already folded into each clock time ("9点5分30秒") is not re-appended. The
931
+ // compactClockTimes window form states its minute separately ("每小时5分") and
932
+ // keeps its own seconds clause, so it does not qualify (returns null). minute 0
933
+ // is handled by composeMinuteZeroClocks before this point.
934
+ function composeSingleMinuteClocks(
935
+ schedule: Schedule, rest: PlanNode, sec: string, opts: Opts
936
+ ): string | null {
937
+ if (rest.kind !== 'clockTimes' || schedule.shapes.minute !== 'single' ||
938
+ clockRestCarriesSecond(rest)) {
939
+ return null;
940
+ }
941
+
942
+ const core =
943
+ render(schedule, rest, opts) + minuteZeroSecondTail(schedule, sec);
944
+
945
+ return isDaily(schedule) ? '每天' + core : core;
946
+ }
947
+
889
948
  // A minute pinned to 0 under specific clock hours (not a compacted cadence): a
890
949
  // bare clock word ("9点") would hide the :00 and leave the second dangling
891
950
  // ("…9点每秒"), reading as the whole hour. Fuse the seconds with the explicit
@@ -907,14 +966,24 @@ function composeMinuteZeroClocks(schedule: Schedule, sec: string): string {
907
966
  // midnight (凌晨0点) and other hours still need it to pin the minute.
908
967
  return hour === 12 ? '正午' : hourWord(hour) + '0分';
909
968
  });
910
- // A pinned minute makes the seconds' own "每分钟" anchor misleading (it is a
911
- // single minute, not every minute), so the stride here drops it.
969
+ const core = joinAnd(clocks) + minuteZeroSecondTail(schedule, sec);
970
+
971
+ return isDaily(schedule) ? '每天' + core : core;
972
+ }
973
+
974
+ // The "的"-fused second tail for a clock time that already names a single pinned
975
+ // minute ("…的每一秒" for a wildcard second, else "…的" + the second's clause).
976
+ // A pinned minute makes the seconds' own "每分钟" anchor misleading (it is a
977
+ // single minute, not every minute), so a stride here drops it.
978
+ function minuteZeroSecondTail(schedule: Schedule, sec: string): string {
979
+ if (sec === '每秒') {
980
+ return '的每一秒';
981
+ }
982
+
912
983
  const nested =
913
984
  strideFromSegments(segmentsOf(schedule, 'second'), '秒', '秒', '');
914
- const tail = sec === '每秒' ? '的每一秒' : '的' + (nested ?? sec);
915
- const core = joinAnd(clocks) + tail;
916
985
 
917
- return isDaily(schedule) ? '每天' + core : core;
986
+ return '' + (nested ?? sec);
918
987
  }
919
988
 
920
989
  // Whether the hour field is a range — or a list whose segments include a
@@ -1004,18 +1073,75 @@ function composeSecondsListed(schedule: Schedule): string {
1004
1073
  }
1005
1074
 
1006
1075
  if (schedule.shapes.hour === 'wildcard') {
1007
- return minutes + ',' + sec;
1076
+ // A wildcard or stepped second is a cadence; the bare comma ("每小时30分,
1077
+ // 每秒") reads as two independent cadences, so fuse the second beneath the
1078
+ // stated minute(s) with "的" ("每小时30分的每一秒"), the same confinement the
1079
+ // minute-stride and pinned-clock paths use. A single/list/range second is a
1080
+ // clock-point, not a cadence, so it keeps the comma.
1081
+ return secondIsCadence(schedule) ?
1082
+ minutes + confinedSecondTail(sec) : minutes + ',' + sec;
1008
1083
  }
1009
1084
 
1010
1085
  const hourCad = unevenHourCadence(schedule);
1011
1086
 
1012
1087
  if (hourCad !== null) {
1013
- return hourCad + ',' + minutes + ',' + sec;
1088
+ // An hour STEP cadence is the sole hour authority, so the minute clause
1089
+ // drops its "每小时" ("每2小时,0至30分,每秒"); a discrete hour list keeps it
1090
+ // (it falls through to the hourFrame branch below with a null cadence).
1091
+ return hourCad + ',' + withoutHourAnchor(minutes) + ',' + sec;
1014
1092
  }
1015
1093
 
1016
1094
  return hourFrame(schedule) + minutes + ',' + sec;
1017
1095
  }
1018
1096
 
1097
+ // Whether the minute field is a stepped cadence (a clean `*/n`, an offset
1098
+ // `m/n`, or a uneven step the core enumerated to an arithmetic fire list). The
1099
+ // shape the seconds-wildcard confinement below fuses with "的".
1100
+ function isMinuteStride(schedule: Schedule): boolean {
1101
+ if (schedule.shapes.minute === 'step') {
1102
+ return true;
1103
+ }
1104
+
1105
+ const values = singleValues(segmentsOf(schedule, 'minute'));
1106
+
1107
+ return values !== null && arithmeticStep(values) !== null;
1108
+ }
1109
+
1110
+ // Whether the second is a CADENCE (wildcard "每秒" or a clean step "每N秒") rather
1111
+ // than a clock-point (a single/list/range). A cadence second under a stated
1112
+ // minute fuses beneath it with "的"; a clock-point keeps the "," connector.
1113
+ function secondIsCadence(schedule: Schedule): boolean {
1114
+ return schedule.pattern.second === '*' || schedule.shapes.second === 'step';
1115
+ }
1116
+
1117
+ // The "的"-fused second tail for a clause that already states its minute(s):
1118
+ // "的每一秒" for a wildcard second, else "的" + the second's own cadence clause.
1119
+ // The fusion binds the second beneath the minute rather than leaving a bare
1120
+ // trailing "每秒" that reads as a second, independent cadence.
1121
+ function confinedSecondTail(sec: string): string {
1122
+ return sec === '每秒' ? '的每一秒' : '的' + sec;
1123
+ }
1124
+
1125
+ // Whether a compose-seconds plan is a stepped minute under a cadence second and
1126
+ // wildcard hour — the shape the "的"-fused confinement below handles, kept
1127
+ // distinct from the */2 even-minutes idiom and the composed-clock paths.
1128
+ function isSteppedMinuteSeconds(
1129
+ schedule: Schedule, composedClock: boolean
1130
+ ): boolean {
1131
+ return !composedClock && schedule.shapes.hour === 'wildcard' &&
1132
+ secondIsCadence(schedule) && schedule.pattern.minute !== '*/2' &&
1133
+ isMinuteStride(schedule);
1134
+ }
1135
+
1136
+ // A stepped minute under a cadence second and wildcard hour: fuse the minute
1137
+ // cadence and the second cadence with "的" ("每小时从4分起每6分钟的每一秒"), never
1138
+ // the comma juxtaposition ("…每6分钟,每秒") that reads as two independent
1139
+ // cadences. The minute clause carries the offset/bound ("从4分起" / ",至58分").
1140
+ function minuteStrideConfinement(schedule: Schedule): string {
1141
+ return minuteHourClause(schedule) +
1142
+ confinedSecondTail(secondClause(schedule));
1143
+ }
1144
+
1019
1145
  // Seconds composed with the minute/hour structure, dispatched on the minute.
1020
1146
  // A single minute over a composed clock-time rest (the core already joined the
1021
1147
  // lone hour and minute into "N点M分") keeps that composition, attaching the
@@ -1028,6 +1154,13 @@ function renderComposeSeconds(
1028
1154
  const composedClock =
1029
1155
  rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes';
1030
1156
 
1157
+ // A stepped minute under a cadence second and wildcard hour confines the
1158
+ // second beneath the minute cadence with "的", never the comma that reads as
1159
+ // two independent cadences. The */2 step keeps its own "每偶数分钟" idiom.
1160
+ if (isSteppedMinuteSeconds(schedule, composedClock)) {
1161
+ return minuteStrideConfinement(schedule);
1162
+ }
1163
+
1031
1164
  if (schedule.pattern.minute === '0' ||
1032
1165
  composedClock && schedule.shapes.minute === 'single') {
1033
1166
  return composeSecondsOnHour(schedule, plan, opts);