x4js 2.3.18 → 2.3.20

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.
Files changed (38) hide show
  1. package/aicli.md +3 -3
  2. package/aicontext.md +51 -23
  3. package/cli/config.mjs +14 -7
  4. package/cli/copy-plugin.mjs +8 -0
  5. package/cli/create.mjs +8 -2
  6. package/cli/dev.mjs +4 -1
  7. package/cli/less-plugin.mjs +5 -1
  8. package/cli/main.mjs +15 -5
  9. package/cli/rawfile-plugin.mjs +23 -3
  10. package/cli/templates.mjs +5 -1
  11. package/package.json +1 -1
  12. package/src/components/calendar/calendar.module.scss +16 -1
  13. package/src/components/calendar/calendar.ts +52 -9
  14. package/src/components/checkbox/checkbox.ts +1 -0
  15. package/src/components/combobox/combobox.ts +51 -23
  16. package/src/components/dialog/dialog.ts +4 -1
  17. package/src/components/form/form.ts +9 -5
  18. package/src/components/gridview/gridview.ts +2 -1
  19. package/src/components/input/input.ts +63 -38
  20. package/src/components/keyboard/keyboard.module.scss +35 -4
  21. package/src/components/keyboard/keyboard.ts +120 -21
  22. package/src/components/listbox/listbox.ts +6 -1
  23. package/src/components/messages/messages.ts +12 -3
  24. package/src/components/popup/popup.ts +19 -20
  25. package/src/components/radio/radio.ts +8 -4
  26. package/src/components/rating/rating.ts +38 -48
  27. package/src/components/slider/slider.ts +38 -8
  28. package/src/components/spreadsheet/spreadsheet.ts +140 -131
  29. package/src/components/switch/switch.ts +2 -1
  30. package/src/components/textarea/textarea.module.scss +2 -4
  31. package/src/components/textarea/textarea.ts +2 -1
  32. package/src/components/textedit/textedit.ts +12 -2
  33. package/src/core/core_application.ts +8 -4
  34. package/src/core/core_data.ts +64 -26
  35. package/src/core/core_dom.ts +9 -1
  36. package/src/core/core_pdf.ts +1200 -46
  37. package/src/core/core_router.ts +42 -44
  38. package/src/core/core_state.ts +22 -5
package/aicli.md CHANGED
@@ -7,7 +7,7 @@ x4js create <project> [--template <name>] [--no-install]
7
7
  x4js templates
8
8
  x4js dev [--config <file>] [--env <file>] [--host <host>] [--port <port>] [--http|--https] [--open]
9
9
  x4js build [--config <file>] [--env <file>] [--debug]
10
- x4js --help | --version
10
+ x4js [<command>] --help | --version
11
11
  ```
12
12
 
13
13
  ## Install
@@ -124,7 +124,7 @@ The file sits at the root of the project. Every key is optional; without the fil
124
124
  | Key | Default | Meaning |
125
125
  |---|---|---|
126
126
  | `entryPoints` | `["src/main.ts"]` | Files to bundle. `src/main.ts` produces `main.js` and `main.css`. |
127
- | `outdir` | `"./bin"` | Output folder. It cannot be the project root. |
127
+ | `outdir` | `"./bin"` | Output folder. It cannot be the project root, nor a folder that contains it. |
128
128
  | `copy` | `[]` | Files or folders copied to the output after each successful build. `to` is relative to `outdir`. |
129
129
  | `external` | `[]` | Module names left out of the bundle. |
130
130
  | `define` | `{}` | Constants replaced at build time. Values are JavaScript expressions written as strings, for example `"\"prod\""`. |
@@ -240,7 +240,7 @@ declare const VERSION_ID: number;
240
240
 
241
241
  | Symptom | Cause |
242
242
  |---|---|
243
- | `index.html` is missing from the output folder | It is not listed in `copy`, or the command that ran is an older global copy. Compare the version on the first line with the project's. |
243
+ | `index.html` is missing from the output folder | `copy: not found: …` in the output: its `from` does not exist. Or it is not listed in `copy`, or the command that ran is an older global copy. Compare the version on the first line with the project's. |
244
244
  | A file placed in the output folder disappeared | `x4js build` empties the folder. Move the file to the sources and add it to `copy`. |
245
245
  | `Cannot find package 'esbuild'` | The dependencies of the `x4js` copy being run are not installed. Run `npm install` in the project. |
246
246
  | `Config file not found` | The file given to `--config` does not exist. Without `--config`, a missing `x4.config.json` is not an error. |
package/aicontext.md CHANGED
@@ -279,7 +279,7 @@ Singleton. Extends `CoreElement` (no DOM of its own). (`src/core/core_applicatio
279
279
  interface AppProps { mountPoint?: string } // CSS selector, default "body"
280
280
 
281
281
  constructor(props?: AppProps) // auto-mounts the mainview on page load
282
- setMainView(view: Component): void
282
+ setMainView(view: Component): void // once, in the constructor of the app: each call sets up the keyboard (Tab, shortcuts) again
283
283
  getMainView(): Component
284
284
  static instance<P extends Application>(): P
285
285
  setEnv(name: string, value: any): void
@@ -577,7 +577,7 @@ class Gauge extends Component {
577
577
 
578
578
  ```ts
579
579
  interface RatingProps extends ComponentProps {
580
- value?: number; steps?: number; icon?: string; name?: string
580
+ value?: number; steps?: number; icon?: string; name?: string // value: the count of stars lit (0 to steps), the star of the value clicked again gives 0; steps: 5 by default; name: for a Form, which reads a number
581
581
  change?: EventCallback<EvChange>
582
582
  }
583
583
  class Rating extends HBox {
@@ -598,12 +598,12 @@ type InputProps = TextInputProps | CheckboxProps | RadioProps | RangeProps
598
598
 
599
599
  interface BaseProps extends ComponentProps {
600
600
  name?: string // required for Form.getValues/setValues
601
- autofocus?: boolean; required?: boolean; readonly?: boolean; placeholder?: string
601
+ autofocus?: boolean; required?: boolean; readonly?: boolean; placeholder?: string // required, readonly: not on checkbox, radio; placeholder: text, email, password, number
602
602
  focus?: EventCallback<EvFocus>; change?: EventCallback<EvChange>
603
603
  }
604
604
  // TextInputProps: type?: "text"|"email"|"password"; value?: string|number; maxlength?, minlength?,
605
605
  // pattern?, spellcheck?, trim?
606
- // NumberProps: type: "number"; value?: string|number; min?, max?, step?
606
+ // NumberProps: type: "number"; value?: string|number; min?, max?, step? // as many decimals shown as the step, 4 at most
607
607
  // RangeProps: type: "range"; min: number; max: number; step?; value?: number
608
608
  // DateProps: type: "date"; value?: string|Date
609
609
  // TimeProps: type: "time"; value?: string
@@ -631,6 +631,7 @@ type TextEditProps = (TextInputProps | NumberProps | DateProps | TimeProps) & {
631
631
  labelWidth?: number; inputWidth?: number; inputId?: string
632
632
  inputGadgets?: Component[]; inputAttrs?: any
633
633
  }
634
+ // style, tooltip, hidden, flex, stretch, height: on the box (label + field); dom_events: on the input
634
635
  class TextEdit extends HBox {
635
636
  getValue(): string; setValue(value: string): void; getInput(): Input
636
637
  }
@@ -641,7 +642,7 @@ class TextEdit extends HBox {
641
642
  ```ts
642
643
  interface SimpleTextAreaProps extends BaseProps { // BaseProps: see Input (name, readonly, required, placeholder, autofocus, focus, change)
643
644
  value?: string
644
- resize?: boolean | "none" | "vertical" | "horizontal" | "both" // false = "none"
645
+ resize?: boolean | "none" | "vertical" | "horizontal" | "both" // no handle by default; true = "both", false = "none"
645
646
  trim?: boolean // getText() trims, unless false
646
647
  }
647
648
  interface TextAreaProps extends SimpleTextAreaProps { label?: string }
@@ -675,6 +676,7 @@ interface CheckboxProps extends ComponentProps {
675
676
  label: string; checked?: boolean; value?: boolean|number|string; name?: string
676
677
  change?: EventCallback<EvChange>
677
678
  }
679
+ // in a Form: without value (or a boolean one), a boolean; with one, the value when checked, null when not
678
680
  class Checkbox extends Component {
679
681
  getCheck(): boolean; setCheck(ck: boolean): void
680
682
  setLabel(text: string): void; toggle(): void
@@ -695,15 +697,15 @@ interface RadioProps extends ComponentProps {
695
697
  class Radio extends Component {
696
698
  getValue(): string; setCheck(ck: boolean): void
697
699
  setLabel(text: string): void
698
- checkValue(vname: string): void // check the radio whose value === vname (group)
700
+ checkValue(vname: string): void // check the radio whose value === vname, in the group of its form (else of the page)
699
701
  }
700
702
  ```
701
703
 
702
704
  ### `Switch`
703
705
 
704
706
  ```ts
705
- interface SwitchProps extends ComponentProps { label: string; checked?: boolean; name?: string }
706
- // with a name, a Form reads and writes it as a boolean
707
+ interface SwitchProps extends ComponentProps { label: string; checked?: boolean; value?: string; name?: string }
708
+ // with a name, a Form reads and writes it: a boolean, or with a value, the value when checked and null when not
707
709
  class Switch extends HBox {}
708
710
  ```
709
711
 
@@ -713,10 +715,12 @@ class Switch extends HBox {}
713
715
  interface SliderProps extends ComponentProps {
714
716
  value: number; min: number; max: number // required
715
717
  step?: number; vertical?: boolean
716
- change?: EventCallback<EvChange>
718
+ name?: string // for a Form, which reads a number
719
+ change?: EventCallback<EvChange> // when the user moves the thumb (drag, arrows), not by the code
717
720
  }
718
721
  class Slider extends Component {
719
- setMin(min: number): void; setMax(max: number): void; setValue(v: number): void
722
+ getValue(): number
723
+ setMin(min: number): void; setMax(max: number): void; setValue(v: number): void // the value is kept between min and max, on the step
720
724
  }
721
725
  ```
722
726
 
@@ -746,13 +750,13 @@ interface ComboboxProps extends ComponentProps {
746
750
  readonly?: boolean // true by default: the user picks, and cannot type. false to let him type
747
751
  renderer?: (item: ListItem) => Component // draws an item of the list (the field still shows item.text)
748
752
  required?: boolean; name?: string
749
- selectionChange?: EventCallback<EvSelectionChange>
753
+ selectionChange?: EventCallback<EvSelectionChange> // at each item reached with the arrows too
750
754
  }
751
755
  class Combobox extends Component {
752
756
  getValue(): string; setValue(value: string): void
753
757
  getInput(): Input
754
- getSelection(): ListboxID
755
- selectItem(index: number): void
758
+ getSelection(): ListboxID // the item chosen (click or arrows); undefined when the text typed is not the one of an item
759
+ selectItem(index: ListboxID): void // null: none, the field is emptied
756
760
  setItems(items): void
757
761
  showDropDown(): void
758
762
  }
@@ -1083,7 +1087,7 @@ class Dialog extends Popup {
1083
1087
  getButton(name: string): Button
1084
1088
  getBtnBar(): BtnGroup
1085
1089
  setTitle(title: string): void
1086
- showAsync(): Promise<string> // resolves with the clicked button name
1090
+ showAsync(): Promise<string> // the name of the clicked button; null when closed without a button (Escape, close box)
1087
1091
  }
1088
1092
  ```
1089
1093
 
@@ -1096,7 +1100,7 @@ class MessageBox extends Dialog {
1096
1100
  }
1097
1101
  class InputBox extends Dialog {
1098
1102
  getValue(): string
1099
- // static showAsync(msg, value, title?, options?: {password?, trim?}): Promise<string> (null if cancelled)
1103
+ // static showAsync(msg, value, title?, options?: {password?, trim?}): Promise<string> (null if cancelled or closed)
1100
1104
  }
1101
1105
  class PromptBox extends Dialog {
1102
1106
  static showAsync(msg: string|UnsafeHtml, editor: Component, title?: string): Promise<string>
@@ -1134,8 +1138,8 @@ class Form extends Box {
1134
1138
  setValues(values: FormValues): void
1135
1139
  getValues<T extends FormValues>(): T
1136
1140
  setAutoComplete(on?: boolean): void
1137
- setValidator(validator: (values: FormValues, is_valid: boolean) => boolean): void
1138
- validate(): FormValues // null if invalid
1141
+ setValidator(validator: (values: FormValues, is_valid: boolean) => boolean): void // a new call replaces the validator
1142
+ validate(): FormValues // null if invalid; the values the validator changes are written back in their fields (any element with the name, every radio of a group)
1139
1143
  }
1140
1144
  // Collects all children (recursive) that have a "name" attribute and implement queryInterface("form-element").
1141
1145
  ```
@@ -1164,7 +1168,9 @@ class Video extends Box {
1164
1168
  get volume(): number; get videoWidth(): number; get videoHeight(): number
1165
1169
  set stream(stream: MediaStream)
1166
1170
  }
1167
- class Calendar extends VBox { getDate(): Date; setDate(date: Date): void }
1171
+ class Calendar extends VBox { // props: date?, minDate?, maxDate? (days out of them cannot be chosen), change ("change": the day clicked)
1172
+ getDate(): Date; setDate(date: Date): void // copies: the calendar keeps its own date
1173
+ }
1168
1174
  class ColorPicker extends VBox { // props: color, swatches?, nullable?; "change": EvColorChange, also fired once when built
1169
1175
  getColor(): Color; setColor(color: Color | string): void; setSwatches(swatches): void
1170
1176
  }
@@ -1177,6 +1183,7 @@ class Keyboard extends HBox { // virtual keyboard, shown while an
1177
1183
  showOn(el: Component): void
1178
1184
  }
1179
1185
  // languages: cs da de en es et fi fr ga hr hu it lt lv mt nl pl pt ro sk sl sv
1186
+ // bottom row: 123 (numeric pad), the letters of the language (éè, none in en), the signs (#+=); Abc comes back
1180
1187
  class TickLine extends Component
1181
1188
  class Header extends HBox // resizable column header; updates target style "--{name}-width"
1182
1189
  ```
@@ -1237,7 +1244,7 @@ class DataStore<T> extends EventSource {
1237
1244
  ```ts
1238
1245
  interface FilterInfo {
1239
1246
  op: "<"|">"|"<="|"="|">="|"<>"|"empty-result"|FilterFunc
1240
- field?: string; value?: string|RegExp; caseSensitive?: boolean
1247
+ field?: string; value?: string|number|RegExp; caseSensitive?: boolean // a RegExp is tested on the field as it is: caseSensitive does not apply, the regexp handles the case (/^jo/i)
1241
1248
  }
1242
1249
  interface SortProp { field: string; ascending: boolean; numeric?: boolean; callback?: SortCallback }
1243
1250
  // Event "view_change" → { change_type: "change"|"filter"|"sort" }
@@ -1268,7 +1275,7 @@ class DataProxy extends CoreElement { load(url?): Promise<void> } // Event "ch
1268
1275
  ```ts
1269
1276
  class Router extends EventSource {
1270
1277
  constructor(useHash?: boolean) // default true (# URLs)
1271
- get(uri: string|RegExp, handler: (params: any, path: string) => void): void
1278
+ get(uri: string|RegExp, handler: (params: any, path: string) => void): void // the first route that matches is called, in the order of get: "/*" last is the route for the rest (params.wild)
1272
1279
  init(): void // trigger the current route
1273
1280
  navigate(uri: string, notify?: boolean, replace?: boolean): boolean
1274
1281
  // Events: "change" → { value: string } ; "error" → { code: 404, message: string }
@@ -1455,7 +1462,7 @@ centerRect(inner: IRect, outer: IRect, margin?): IRect
1455
1462
  sprintf(format, ...args): string // replaces {0} {1}...
1456
1463
  pad(what, size, ch?): string // >0 padEnd, <0 padStart
1457
1464
  pascalCase(s): string; camelCase(s): string; clamp<T>(v, min, max): T
1458
- measureText(str, size): number
1465
+ measureText(str, size, bold?): number // width in Helvetica, from core_pdf (see PDF)
1459
1466
 
1460
1467
  // Dates
1461
1468
  date_format(date, options?): string
@@ -1526,10 +1533,31 @@ class SvgBuilder extends SvgGroup {
1526
1533
  ## PDF (`core_pdf`)
1527
1534
 
1528
1535
  ```ts
1536
+ interface PDFProps { title?: string; author?: string; subject?: string }
1529
1537
  class X4PDFBuilder {
1530
- page(width: number, height: number, callback): void
1538
+ constructor(props?: PDFProps)
1539
+ page(width: number, height: number, callback: (ctx: PageContext) => void): void // points; without size 612 x 792
1531
1540
  build(): Uint8Array
1532
- }
1541
+ buildAsync(): Promise<Uint8Array> // streams compressed (FlateDecode)
1542
+ }
1543
+ export interface PageContext { // origin bottom left, y goes up
1544
+ text(str, x, y, size, opts?: { align?: 'left'|'center'|'right'; width?; color?; bold? }): void
1545
+ rect(x, y, w, h, fill: string): void
1546
+ line(x1, y1, x2, y2, stroke: string, lineWidth?): void
1547
+ path(segments: PathSegment[], fill: string, stroke?: string, lineWidth?): void // null: not drawn
1548
+ image(jpegBytes: Uint8Array, x, y, w, h): void
1549
+ link(url, x, y, w, h, opts?: { underline?: string }): void
1550
+ barcode(type: BarcodeType, text, x, y, w, h, opts?: BarcodeOptions): void // datamatrix: square min(w,h), (x,y) bottom left; code128: bars on w x h; no quiet zone
1551
+ svgPath(d: string, x, y, scale, fill: string, stroke?: string, lineWidth?): void // (x,y): the svg origin, its y going down; M L H V C S Q T A Z
1552
+ icon(svg: string, x, y, w, h, opts?: { color?: string; background?: string }): void // as Icon takes it (not a file); viewBox fitted in w x h; opacity mixed with background
1553
+ }
1554
+ type PathSegment = ['m', x, y] | ['l', x, y] | ['c', x1, y1, x2, y2, x3, y3] | ['h']
1555
+ type BarcodeType = 'datamatrix' | 'code128'
1556
+ interface BarcodeOptions { gs1?: boolean /* "(01)...(17)..." */; color?: string }
1557
+ renderBarcodeImage(type, text, opts?: BarcodeOptions & { module?: number }): string // "data:image/svg+xml,..." for Image / Icon; on white, quiet zone included
1558
+ // Data Matrix ECC200 10x10..26x26 (44 chars, 88 digits, Latin-1); Code 128 ASCII. Too long or not encodable: throws.
1559
+ measureText(str: string, size: number, bold?: boolean): number // Helvetica
1560
+ // Text: Helvetica, Windows-1252 (é € … ’); another character comes out as '?'. Printed at its size (PrintScaling /None).
1533
1561
  ```
1534
1562
 
1535
1563
  ---
package/cli/config.mjs CHANGED
@@ -92,16 +92,20 @@ function readJson(filename) {
92
92
  }
93
93
  }
94
94
 
95
- function validateCopy(copy) {
95
+ function validateCopy(copy, env) {
96
96
  if (!Array.isArray(copy))
97
97
  throw new Error("copy must be an array");
98
98
 
99
99
  for (const item of copy) {
100
100
  if (!item || typeof item !== "object" || typeof item.from !== "string" || typeof item.to !== "string")
101
101
  throw new Error("Each copy entry must contain string 'from' and 'to' fields");
102
- if (path.isAbsolute(item.to))
102
+
103
+ // checked once expanded: a $NAME could hold "..".
104
+ // A root: "D:assets" is not absolute, but goes to the folder of D:
105
+ const to = expandEnv(item.to, env);
106
+ if (path.isAbsolute(to) || path.parse(to).root)
103
107
  throw new Error(`copy destination must be relative to outdir: '${item.to}'`);
104
- const normalized = path.normalize(item.to);
108
+ const normalized = path.normalize(to);
105
109
  if (normalized === ".." || normalized.startsWith(`..${path.sep}`))
106
110
  throw new Error(`copy destination escapes outdir: '${item.to}'`);
107
111
  }
@@ -125,7 +129,7 @@ export function loadConfig(root = process.cwd(), configFile = DEFAULT_CONFIG_FIL
125
129
  throw new Error("entryPoints must be a non-empty array of strings");
126
130
 
127
131
  const copy = cfg.copy ?? DEFAULTS.copy;
128
- validateCopy(copy);
132
+ validateCopy(copy, env);
129
133
 
130
134
  const external = cfg.external ?? DEFAULTS.external;
131
135
  if (!Array.isArray(external) || external.some((entry) => typeof entry !== "string"))
@@ -165,11 +169,14 @@ export function loadConfig(root = process.cwd(), configFile = DEFAULT_CONFIG_FIL
165
169
  throw new Error("outdir must be a string");
166
170
 
167
171
  const outdir = resolvePath(root, outdirRaw, env);
168
- if (outdir === root)
169
- throw new Error("outdir cannot be the project root");
170
172
  if (outdir === path.parse(outdir).root)
171
173
  throw new Error("outdir cannot be a filesystem root");
172
174
 
175
+ // the output folder is emptied before each build: it must not be the project, nor a folder that holds it
176
+ const fromOutdir = path.relative(outdir, root);
177
+ if (fromOutdir !== ".." && !fromOutdir.startsWith(`..${path.sep}`) && !path.isAbsolute(fromOutdir))
178
+ throw new Error(`outdir cannot be the project root, nor a folder that contains it: '${outdir}'`);
179
+
173
180
  let esbuildOptions = {};
174
181
  if (cfg.esbuild !== undefined) {
175
182
  if (!cfg.esbuild || typeof cfg.esbuild !== "object" || Array.isArray(cfg.esbuild))
@@ -184,7 +191,7 @@ export function loadConfig(root = process.cwd(), configFile = DEFAULT_CONFIG_FIL
184
191
  outdir,
185
192
  copy: copy.map((item) => ({
186
193
  from: resolvePath(root, item.from, env),
187
- to: path.normalize(item.to),
194
+ to: path.normalize(expandEnv(item.to, env)),
188
195
  })),
189
196
  external: [...external],
190
197
  define: Object.fromEntries(
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { warning } from "./log.mjs";
3
4
 
4
5
  async function walk(source) {
5
6
  const files = [];
@@ -51,6 +52,13 @@ export function copyPlugin(config, { dev = false } = {}) {
51
52
  for (const entry of entries) {
52
53
  const result = await walk(entry.from);
53
54
  entry.sourceIsDirectory = result.isDirectory;
55
+
56
+ // its folder is watched: in dev, its creation starts a build
57
+ if (!result.files.length && !result.isDirectory) {
58
+ warning(`copy: not found: ${entry.from}`);
59
+ dirs.push(path.dirname(entry.from));
60
+ }
61
+
54
62
  dirs.push(...result.dirs);
55
63
  for (const item of result.files) {
56
64
  files.push({ entry, ...item });
package/cli/create.mjs CHANGED
@@ -127,8 +127,10 @@ export async function create(argv = [], { version = "unknown", cwd = process.cwd
127
127
  try {
128
128
  const files = await fetchTemplateFiles(values.template, version);
129
129
 
130
- await Promise.all(files.map(async (file) => {
131
- if (!file.path || file.path.split("/").includes(".."))
130
+ // a \ is a separator on Windows: "..\x" would go out of the target
131
+ // allSettled: every write is over before the cleanup of the catch
132
+ const results = await Promise.allSettled(files.map(async (file) => {
133
+ if (!file.path || file.path.includes("\\") || file.path.split("/").includes(".."))
132
134
  throw new Error(`Unsafe template path '${file.path}'`);
133
135
  const destination = path.join(target, ...file.path.split("/"));
134
136
  await fs.mkdir(path.dirname(destination), { recursive: true });
@@ -136,6 +138,10 @@ export async function create(argv = [], { version = "unknown", cwd = process.cwd
136
138
  await fs.writeFile(destination, data);
137
139
  }));
138
140
 
141
+ const failed = results.find((result) => result.status === "rejected");
142
+ if (failed)
143
+ throw failed.reason;
144
+
139
145
  await patchPackage(target, projectName, version);
140
146
  projectReady = true;
141
147
 
package/cli/dev.mjs CHANGED
@@ -5,7 +5,7 @@ import { parseArgs } from "node:util";
5
5
  import esbuild from "esbuild";
6
6
  import { DEFAULT_CONFIG_FILE, expandEnv, loadConfig, loadEnvFile, resolveConfigFile, resolveTls } from "./config.mjs";
7
7
  import { createBuildOptions } from "./build-options.mjs";
8
- import { info, success, failure } from "./log.mjs";
8
+ import { info, success, failure, warning } from "./log.mjs";
9
9
 
10
10
  function openBrowser(url) {
11
11
  let command;
@@ -28,6 +28,9 @@ function openBrowser(url) {
28
28
  detached: true,
29
29
  stdio: "ignore",
30
30
  });
31
+
32
+ // no browser could be started (xdg-open missing...): the server goes on
33
+ child.on("error", (error) => warning(`cannot open the browser: ${error.message}`));
31
34
  child.unref();
32
35
  }
33
36
 
@@ -28,7 +28,11 @@ export function lessPlugin(projectRoot) {
28
28
  setup(build) {
29
29
  build.onLoad({ filter: /\.less$/ }, async (args) => {
30
30
  const source = await fs.readFile(args.path, "utf8");
31
- lessPromise ??= loadLess(projectRoot);
31
+ // not kept when it fails: less can be installed while dev runs
32
+ lessPromise ??= loadLess(projectRoot).catch((error) => {
33
+ lessPromise = undefined;
34
+ throw error;
35
+ });
32
36
  const less = await lessPromise;
33
37
 
34
38
  const result = await less.render(source, {
package/cli/main.mjs CHANGED
@@ -25,17 +25,27 @@ Usage:
25
25
  x4js build [--config <file>] [--env <file>] [--debug]
26
26
 
27
27
  Options:
28
- --config <file> Config file (default: x4.config.json)
29
- --env <file> File of NAME=value lines, for $NAME in the config
30
- -h, --help Show help
31
- -v, --version Show version`);
28
+ --config <file> Config file (default: x4.config.json)
29
+ --env <file> File of NAME=value lines, for $NAME in the config
30
+ -t, --template <name> create: template to use (default: app)
31
+ --no-install create: do not run npm install
32
+ --host <host> dev: address to listen on (default: 127.0.0.1)
33
+ --port <port> dev: port to listen on (default: the first free port from 8000)
34
+ --http, --https dev: force the protocol, whatever the config says
35
+ --open dev: open the browser once the server is ready
36
+ --debug build: not minified, with source maps
37
+ -h, --help Show help
38
+ -v, --version Show version`);
32
39
  }
33
40
 
34
41
  const version = getVersion();
35
42
  const [command, ...argv] = process.argv.slice(2);
36
43
 
44
+ // x4js <command> --help: the help, that gives the options of every command
45
+ const help = argv.includes("--help") || argv.includes("-h");
46
+
37
47
  try {
38
- switch (command) {
48
+ switch (help ? "help" : command) {
39
49
  case "create": {
40
50
  const { create } = await import("./create.mjs");
41
51
  await create(argv, { version });
@@ -8,15 +8,35 @@ export function rawFilePlugin({ filter, mime }) {
8
8
  name: "x4-raw-file",
9
9
 
10
10
  setup(build) {
11
- build.onResolve({ filter }, (args) => {
11
+ build.onResolve({ filter }, async (args) => {
12
12
  // Seulement les imports JS/TS.
13
+ // pluginData: the call of build.resolve below, which esbuild resolves itself
13
14
  if (
14
- args.kind !== "import-statement" &&
15
- args.kind !== "dynamic-import"
15
+ (args.kind !== "import-statement" &&
16
+ args.kind !== "dynamic-import") ||
17
+ args.pluginData === namespace
16
18
  ) {
17
19
  return;
18
20
  }
19
21
 
22
+ // a package: esbuild finds it in node_modules. Its file is read here
23
+ // too, in base64: Icon inserts the svg of the url as it is, a "%23"
24
+ // of a url in text would stay in it
25
+ if (!args.path.startsWith(".") && !path.isAbsolute(args.path)) {
26
+ const found = await build.resolve(args.path, {
27
+ kind: args.kind,
28
+ resolveDir: args.resolveDir,
29
+ importer: args.importer,
30
+ pluginData: namespace,
31
+ });
32
+
33
+ if (found.errors.length) {
34
+ return { errors: found.errors };
35
+ }
36
+
37
+ return { path: found.path, namespace };
38
+ }
39
+
20
40
  return {
21
41
  path: path.resolve(args.resolveDir, args.path),
22
42
  namespace,
package/cli/templates.mjs CHANGED
@@ -1,7 +1,11 @@
1
+ import { parseArgs } from "node:util";
1
2
  import { fetchTemplates } from "./templates-source.mjs";
2
3
  import { color } from "./log.mjs";
3
4
 
4
- export async function templates(_argv = [], { version = "unknown" } = {}) {
5
+ export async function templates(argv = [], { version = "unknown" } = {}) {
6
+ // takes no argument: one given is an error
7
+ parseArgs({ args: argv, options: {}, strict: true });
8
+
5
9
  const manifest = await fetchTemplates(version);
6
10
  const names = Object.keys(manifest).sort();
7
11
  const width = names.reduce((max, name) => Math.max(max, name.length), 0) + 3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x4js",
3
- "version": "2.3.18",
3
+ "version": "2.3.20",
4
4
  "type": "module",
5
5
  "main": "src/x4.ts",
6
6
  "module": "src/x4.ts",
@@ -41,7 +41,10 @@
41
41
  .x4calendar {
42
42
  border: 1px solid var( --border );
43
43
  padding: 4px;
44
-
44
+
45
+ // too short for its weeks (6 in some months): what does not fit is cut, not drawn over what follows
46
+ overflow: hidden;
47
+
45
48
  .month-sel {
46
49
  height: 2em;
47
50
  align-items: center;
@@ -147,6 +150,18 @@
147
150
  color: var( --calendard-day-color-hover );
148
151
  }
149
152
  }
153
+
154
+ // out of minDate..maxDate: cannot be chosen
155
+ .disabled {
156
+ color: var( --calendar-out-color );
157
+ text-decoration: line-through;
158
+ cursor: default;
159
+ }
160
+
161
+ .disabled:hover .text {
162
+ background-color: transparent;
163
+ color: var( --calendar-out-color );
164
+ }
150
165
  }
151
166
 
152
167
  .header {
@@ -78,7 +78,7 @@ export class Calendar extends VBox<CalendarProps, CalendarEventMap>
78
78
  super(props);
79
79
 
80
80
  this.mapPropEvents( props, 'change' );
81
- this.m_date = props.date ? date_clone( props.date ) : new Date();
81
+ this.m_date = this._clamp( props.date ? date_clone( props.date ) : new Date() );
82
82
 
83
83
  this._update( );
84
84
  }
@@ -130,7 +130,7 @@ export class Calendar extends VBox<CalendarProps, CalendarEventMap>
130
130
  }),
131
131
  new Flex( ),
132
132
  new Button({ icon: icon_prev, click: () => this._next(false) } ),
133
- new Button({ icon: icon_today, click: () => this.setDate(new Date()), tooltip: _tr.global.today } ),
133
+ new Button({ icon: icon_today, click: () => this.setDate( this._clamp( new Date() ) ), tooltip: _tr.global.today } ),
134
134
  new Button({ icon: icon_next, click: () => this._next(true) } )
135
135
  ]
136
136
  });
@@ -159,6 +159,9 @@ export class Calendar extends VBox<CalendarProps, CalendarEventMap>
159
159
  }));
160
160
 
161
161
  let cmonth = this.m_date.getMonth();
162
+ const { minDate, maxDate } = this.props;
163
+ const min_hash = minDate ? date_hash( minDate ) : null;
164
+ const max_hash = maxDate ? date_hash( maxDate ) : null;
162
165
 
163
166
  // weeks
164
167
  let first = true;
@@ -184,6 +187,13 @@ export class Calendar extends VBox<CalendarProps, CalendarEventMap>
184
187
  cls += ' out';
185
188
  }
186
189
 
190
+ // out of minDate..maxDate: shown, not chosen
191
+ const hash = date_hash( dte );
192
+ const off = (min_hash!==null && hash < min_hash) || (max_hash!==null && hash > max_hash);
193
+ if( off ) {
194
+ cls += ' disabled';
195
+ }
196
+
187
197
  const mkItem = ( dte: Date ) => {
188
198
  return new HBox({
189
199
  cls,
@@ -192,7 +202,7 @@ export class Calendar extends VBox<CalendarProps, CalendarEventMap>
192
202
  cls: "text",
193
203
  content: unsafeHtml( `<span>${formatIntlDate(dte, 'd')}</span>` ),
194
204
  }),
195
- dom_events: {
205
+ dom_events: off ? undefined : {
196
206
  click: () => this.select(dte)
197
207
  }
198
208
  })
@@ -230,8 +240,40 @@ export class Calendar extends VBox<CalendarProps, CalendarEventMap>
230
240
  */
231
241
 
232
242
  private _next(n: boolean) {
233
- this.m_date.setMonth(this.m_date.getMonth() + (n ? 1 : -1));
234
- this._update();
243
+ this._goto( this.m_date.getFullYear( ), this.m_date.getMonth() + (n ? 1 : -1) );
244
+ }
245
+
246
+ /**
247
+ * shows another month: the day is kept, not past the end of the month
248
+ * (31 January → 28 February), and the date stays between minDate and maxDate
249
+ */
250
+
251
+ private _goto( year: number, month: number ) {
252
+ const day = this.m_date.getDate( );
253
+ const d = date_clone( this.m_date );
254
+ d.setDate( 1 );
255
+ d.setFullYear( year, month ); // month 12: January of the next year
256
+ d.setDate( Math.min( day, new Date( d.getFullYear( ), d.getMonth( )+1, 0 ).getDate( ) ) );
257
+
258
+ this.m_date = this._clamp( d );
259
+ this._update( );
260
+ }
261
+
262
+ /**
263
+ * the date brought between minDate and maxDate
264
+ */
265
+
266
+ private _clamp( d: Date ) {
267
+ const { minDate, maxDate } = this.props;
268
+ if( minDate && d < minDate ) {
269
+ return date_clone( minDate );
270
+ }
271
+
272
+ if( maxDate && d > maxDate ) {
273
+ return date_clone( maxDate );
274
+ }
275
+
276
+ return d;
235
277
  }
236
278
 
237
279
  /**
@@ -246,7 +288,7 @@ export class Calendar extends VBox<CalendarProps, CalendarEventMap>
246
288
  for (let m = 0; m < 12; m++) {
247
289
  items.push(({
248
290
  text: _tr.global.month_long[m],
249
- click: () => { this.m_date.setMonth(m); this._update(); }
291
+ click: () => { this._goto( this.m_date.getFullYear( ), m ); }
250
292
  }));
251
293
  }
252
294
  }
@@ -258,7 +300,7 @@ export class Calendar extends VBox<CalendarProps, CalendarEventMap>
258
300
  for (let m = max; m >= min; m--) {
259
301
  items.push({
260
302
  text: '' + m,
261
- click: () => { this.m_date.setFullYear(m); this._update(); }
303
+ click: () => { this._goto( m, this.m_date.getMonth( ) ); }
262
304
  });
263
305
  }
264
306
  }
@@ -271,12 +313,13 @@ export class Calendar extends VBox<CalendarProps, CalendarEventMap>
271
313
  menu.displayAt(rc.left, rc.top);
272
314
  }
273
315
 
316
+ // a copy: the calendar and its caller do not share the object
274
317
  getDate() {
275
- return this.m_date;
318
+ return date_clone( this.m_date );
276
319
  }
277
320
 
278
321
  setDate(date: Date) {
279
- this.m_date = date;
322
+ this.m_date = date_clone( date );
280
323
  this._update();
281
324
  }
282
325
  }
@@ -71,6 +71,7 @@ export class Checkbox extends Component<CheckboxProps,CheckBoxEvents> {
71
71
  type:"checkbox",
72
72
  id: inputId,
73
73
  name: props.name,
74
+ value: props.value, // sent by a <form> of the browser when checked; a Form of x4 reads a boolean
74
75
  checked: props.checked,
75
76
  dom_events: {
76
77
  change: ( ) => this._on_change( ),