hotstaq 0.9.21 → 0.9.23

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hotstaq",
3
- "version": "0.9.21",
3
+ "version": "0.9.23",
4
4
  "description": "A friendly web framework that fits nicely into devops and CI/CD pipelines.",
5
5
  "bin": {
6
6
  "hotstaq": "./bin/hotstaq"
package/src/HotStaq.ts CHANGED
@@ -238,7 +238,7 @@ export class HotStaq implements IHotStaq
238
238
  /**
239
239
  * The current version of HotStaq.
240
240
  */
241
- static version: string = "0.9.21";
241
+ static version: string = "0.9.23";
242
242
  /**
243
243
  * Indicates if this is a web build.
244
244
  */
@@ -297,9 +297,23 @@ export class HotStaq implements IHotStaq
297
297
  */
298
298
  static onBeforeNavigate: (path: string) => boolean | Promise<boolean> = null;
299
299
  /**
300
- * Event fired after SPA navigation completes.
300
+ * Event fired after an SPA navigation has rendered the new content,
301
+ * but before HotStaq executes that content's inline <script> tags.
302
+ *
303
+ * Return `false` to take ownership of running those scripts yourself —
304
+ * HotStaq will then skip its own execution pass. Returning `true`,
305
+ * `undefined`, or having no handler at all lets HotStaq execute them
306
+ * (the default), which is what makes inline page scripts run on SPA
307
+ * navigation. See `onAfterUseOutput` for the full-render equivalent.
308
+ */
309
+ static onAfterNavigate: (path: string) => void | boolean | Promise<void | boolean> = null;
310
+ /**
311
+ * Event fired after `useOutput` has rendered a full document, but before
312
+ * HotStaq executes that document's inline <script> tags. Same return-value
313
+ * gate as `onAfterNavigate`: return `false` to run the scripts yourself;
314
+ * otherwise HotStaq executes them.
301
315
  */
302
- static onAfterNavigate: (path: string) => void | Promise<void> = null;
316
+ static onAfterUseOutput: (output: string) => void | boolean | Promise<void | boolean> = null;
303
317
  /**
304
318
  * Override the framework's default 404 rendering.
305
319
  *
@@ -763,9 +777,7 @@ export class HotStaq implements IHotStaq
763
777
 
764
778
  HotStaq.baseValidator (options, key, validation, value, 100);
765
779
 
766
- const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/;
767
-
768
- if (uuidRegex.test (value) === false)
780
+ if (HotStaq.isUUID (value) === false)
769
781
  throw new HttpError(`UUID parameter '${key}' must be a valid UUID.`, 400);
770
782
 
771
783
  return ({ value: value });
@@ -1083,6 +1095,82 @@ export class HotStaq implements IHotStaq
1083
1095
  return (false);
1084
1096
  }
1085
1097
 
1098
+ /**
1099
+ * Check whether a value is a valid RFC-4122 UUID (versions 1-5).
1100
+ *
1101
+ * This is the same strict check the `UUID` validator enforces, exposed as
1102
+ * a reusable boolean helper so client code can validate ids (e.g. a
1103
+ * `?id=` URL parameter) without catching the validator's thrown HttpError.
1104
+ */
1105
+ static isUUID (value: any): boolean
1106
+ {
1107
+ if (typeof (value) !== "string")
1108
+ return (false);
1109
+
1110
+ return (HotStaq.uuidRegex.test (value));
1111
+ }
1112
+
1113
+ /**
1114
+ * The strict RFC-4122 (versions 1-5) UUID pattern shared by `isUUID` and
1115
+ * the `UUID` validator.
1116
+ */
1117
+ static uuidRegex: RegExp = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/;
1118
+
1119
+ /**
1120
+ * Remove elements already in the document that share an id with `subtree`
1121
+ * (or any of its descendants).
1122
+ *
1123
+ * A component placed via `documentSelector` lands OUTSIDE its own <tag> —
1124
+ * e.g. <admin-edit> appends its modal to <body>. On SPA navigation the
1125
+ * <tag> upgrades again and appends a SECOND copy with the same id, leaving
1126
+ * a stale orphan that id-based lookups (document.getElementById,
1127
+ * querySelector('#id'), bootstrap.Modal.getInstance('#id')) resolve to
1128
+ * instead of the live one. Call this with the incoming subtree BEFORE it is
1129
+ * inserted so only the fresh copy survives.
1130
+ *
1131
+ * `subtree` is expected to still be detached at call time; the contains()
1132
+ * guard keeps it (and anything inside it) safe regardless.
1133
+ */
1134
+ static removeStaleDuplicateIds (subtree: HTMLElement): void
1135
+ {
1136
+ if ((subtree == null) || (typeof (document) === "undefined"))
1137
+ return;
1138
+
1139
+ let ids: string[] = [];
1140
+
1141
+ if ((subtree.id != null) && (subtree.id !== ""))
1142
+ ids.push (subtree.id);
1143
+
1144
+ let descendants: NodeListOf<Element> = subtree.querySelectorAll ("[id]");
1145
+
1146
+ for (let iIdx = 0; iIdx < descendants.length; iIdx++)
1147
+ {
1148
+ let id: string = descendants[iIdx].getAttribute ("id");
1149
+
1150
+ if ((id != null) && (id !== ""))
1151
+ ids.push (id);
1152
+ }
1153
+
1154
+ for (let iIdx = 0; iIdx < ids.length; iIdx++)
1155
+ {
1156
+ let id: string = ids[iIdx];
1157
+ // getElementById returns the first match in document order; loop so
1158
+ // multiple accumulated orphans all get cleared. The guard bounds it.
1159
+ let guard: number = 0;
1160
+ let existing: HTMLElement = document.getElementById (id);
1161
+
1162
+ while ((existing != null) && (existing !== subtree) &&
1163
+ (subtree.contains (existing) === false) && (guard < 100))
1164
+ {
1165
+ if (existing.parentNode != null)
1166
+ existing.parentNode.removeChild (existing);
1167
+
1168
+ existing = document.getElementById (id);
1169
+ guard++;
1170
+ }
1171
+ }
1172
+ }
1173
+
1086
1174
  /**
1087
1175
  * Sanitize an HTML string.
1088
1176
  */
@@ -2848,72 +2936,98 @@ export class HotStaq implements IHotStaq
2848
2936
  }
2849
2937
 
2850
2938
  /**
2851
- * Replace the current HTML page with the output.
2852
- * This is meant for web browser use only.
2939
+ * Execute the <script> elements found within `root`.
2940
+ *
2941
+ * A <script> inserted into the DOM via `innerHTML` is inert — the HTML
2942
+ * spec only runs a script when it is *inserted into the document with
2943
+ * its content already present*. So after we swap new markup in (full
2944
+ * render or SPA partial swap), its inline scripts never ran. For each
2945
+ * not-yet-executed <script>, build a fresh element, copy `type`/`src`
2946
+ * and — for inline scripts — set `.text` BEFORE insertion, then swap it
2947
+ * in so the browser runs it.
2948
+ *
2949
+ * Each executed script is tagged `data-hotstaq-executed` and skipped on
2950
+ * any later pass, so calling this twice (or alongside an app that runs
2951
+ * some scripts itself) never double-fires one.
2853
2952
  */
2854
- static async useOutput (output: string): Promise<void>
2953
+ protected static async executeInlineScripts (root: Document | HTMLElement): Promise<void>
2855
2954
  {
2856
- if (HotStaq.onOutputReceived != null)
2857
- HotStaq.onOutputReceived (output);
2955
+ let tmpScripts = root.getElementsByTagName ("script");
2858
2956
 
2859
- let parser = new DOMParser ();
2860
- let child = parser.parseFromString (output, "text/html");
2861
- let htmlObj: HTMLHtmlElement = document.getElementsByTagName('html')[0];
2957
+ if (tmpScripts.length < 1)
2958
+ return;
2862
2959
 
2863
- htmlObj.innerHTML = child.getElementsByTagName('html')[0].innerHTML;
2960
+ // Snapshot first — the loop mutates the DOM while iterating.
2961
+ let scripts: HTMLScriptElement[] = [];
2864
2962
 
2865
- // Thanks to newfurniturey at:
2866
- // https://stackoverflow.com/questions/22945884/domparser-appending-script-tags-to-head-body-but-not-executing
2867
- let tmpScripts = document.getElementsByTagName('script');
2868
- if (tmpScripts.length > 0) {
2869
- // push all of the document's script tags into an array
2870
- // (to prevent dom manipulation while iterating over dom nodes)
2871
- let scripts: HTMLScriptElement[] = [];
2872
- for (let i = 0; i < tmpScripts.length; i++) {
2873
- scripts.push(tmpScripts[i]);
2874
- }
2963
+ for (let i = 0; i < tmpScripts.length; i++)
2964
+ scripts.push (tmpScripts[i]);
2875
2965
 
2876
- // iterate over all script tags and create duplicate tags for each
2877
- for (let i = 0; i < scripts.length; i++) {
2878
- let s: HTMLScriptElement = document.createElement('script');
2966
+ for (let i = 0; i < scripts.length; i++)
2967
+ {
2968
+ let orig: HTMLScriptElement = scripts[i];
2879
2969
 
2880
- // add the new node to the page
2881
- scripts[i].parentNode.appendChild(s);
2970
+ if (orig.getAttribute ("data-hotstaq-executed") != null)
2971
+ continue;
2882
2972
 
2883
- // remove the original (non-executing) node from the page
2884
- scripts[i].parentNode.removeChild(scripts[i]);
2973
+ let src: string = orig.getAttribute ("src");
2974
+ let hasSrc: boolean = (src != null) && (src !== "");
2975
+ let content: string = orig.textContent || "";
2885
2976
 
2886
- await new Promise<void> ((resolve2, reject2) =>
2887
- {
2888
- s.onload = () =>
2889
- {
2890
- resolve2 ();
2891
- };
2977
+ // Nothing to run just mark it so we don't revisit it.
2978
+ if ((hasSrc === false) && (content === ""))
2979
+ {
2980
+ orig.setAttribute ("data-hotstaq-executed", "1");
2892
2981
 
2893
- let hasSrc: boolean = false;
2982
+ continue;
2983
+ }
2894
2984
 
2895
- if (scripts[i].getAttribute ("src") != null)
2896
- {
2897
- if (scripts[i].getAttribute ("src") !== "")
2898
- {
2899
- s.setAttribute ("src", scripts[i].getAttribute ("src"));
2900
- hasSrc = true;
2901
- }
2902
- }
2985
+ let s: HTMLScriptElement = document.createElement ("script");
2986
+ let type: string = orig.getAttribute ("type");
2903
2987
 
2904
- if (scripts[i].getAttribute ("type") != null)
2905
- {
2906
- if (scripts[i].getAttribute ("type") !== "")
2907
- s.setAttribute ("type", scripts[i].getAttribute ("type"));
2908
- }
2988
+ if ((type != null) && (type !== ""))
2989
+ s.setAttribute ("type", type);
2909
2990
 
2910
- s.innerHTML = scripts[i].innerHTML;
2991
+ if (hasSrc === true)
2992
+ s.setAttribute ("src", src);
2993
+ else
2994
+ // MUST be set before insertion, otherwise the script is inert.
2995
+ s.text = content;
2911
2996
 
2912
- if (hasSrc === false)
2913
- resolve2 ();
2914
- });
2915
- }
2997
+ s.setAttribute ("data-hotstaq-executed", "1");
2998
+
2999
+ await new Promise<void> ((resolve2, reject2) =>
3000
+ {
3001
+ s.onload = () => { resolve2 (); };
3002
+ s.onerror = () => { resolve2 (); };
3003
+
3004
+ if (orig.parentNode != null)
3005
+ orig.parentNode.replaceChild (s, orig);
3006
+ else
3007
+ document.head.appendChild (s);
3008
+
3009
+ // Inline scripts execute synchronously on insertion; only
3010
+ // src scripts need to wait for onload.
3011
+ if (hasSrc === false)
3012
+ resolve2 ();
3013
+ });
2916
3014
  }
3015
+ }
3016
+
3017
+ /**
3018
+ * Replace the current HTML page with the output.
3019
+ * This is meant for web browser use only.
3020
+ */
3021
+ static async useOutput (output: string): Promise<void>
3022
+ {
3023
+ if (HotStaq.onOutputReceived != null)
3024
+ HotStaq.onOutputReceived (output);
3025
+
3026
+ let parser = new DOMParser ();
3027
+ let child = parser.parseFromString (output, "text/html");
3028
+ let htmlObj: HTMLHtmlElement = document.getElementsByTagName('html')[0];
3029
+
3030
+ htmlObj.innerHTML = child.getElementsByTagName('html')[0].innerHTML;
2917
3031
 
2918
3032
  if (HotStaq.dispatchReadyEvents === true)
2919
3033
  {
@@ -2923,6 +3037,27 @@ export class HotStaq implements IHotStaq
2923
3037
 
2924
3038
  if (HotStaq.onReadyEvent != null)
2925
3039
  HotStaq.onReadyEvent (output);
3040
+
3041
+ // Run the rendered document's inline scripts last — and let the app
3042
+ // gate it. onAfterUseOutput returning false means the app will run
3043
+ // them itself, so HotStaq stands down. Otherwise HotStaq executes
3044
+ // them (the default), which is what makes inline scripts in a
3045
+ // useOutput-rendered page actually run.
3046
+ let runScripts: boolean = true;
3047
+
3048
+ if (HotStaq.onAfterUseOutput != null)
3049
+ {
3050
+ let result: any = HotStaq.onAfterUseOutput (output);
3051
+
3052
+ if (result instanceof Promise)
3053
+ result = await result;
3054
+
3055
+ if (result === false)
3056
+ runScripts = false;
3057
+ }
3058
+
3059
+ if (runScripts === true)
3060
+ await HotStaq.executeInlineScripts (document);
2926
3061
  }
2927
3062
 
2928
3063
  /**
@@ -3091,55 +3226,6 @@ export class HotStaq implements IHotStaq
3091
3226
  // Replace only the target content.
3092
3227
  targetEl.innerHTML = contentHTML;
3093
3228
 
3094
- // Execute any script tags that were in the new content.
3095
- let tmpScripts = targetEl.getElementsByTagName ('script');
3096
-
3097
- if (tmpScripts.length > 0)
3098
- {
3099
- let scripts: HTMLScriptElement[] = [];
3100
-
3101
- for (let i = 0; i < tmpScripts.length; i++)
3102
- scripts.push (tmpScripts[i]);
3103
-
3104
- for (let i = 0; i < scripts.length; i++)
3105
- {
3106
- let s: HTMLScriptElement = document.createElement ('script');
3107
-
3108
- scripts[i].parentNode.appendChild (s);
3109
- scripts[i].parentNode.removeChild (scripts[i]);
3110
-
3111
- await new Promise<void> ((resolve2, reject2) =>
3112
- {
3113
- s.onload = () =>
3114
- {
3115
- resolve2 ();
3116
- };
3117
-
3118
- let hasSrc: boolean = false;
3119
-
3120
- if (scripts[i].getAttribute ("src") != null)
3121
- {
3122
- if (scripts[i].getAttribute ("src") !== "")
3123
- {
3124
- s.setAttribute ("src", scripts[i].getAttribute ("src"));
3125
- hasSrc = true;
3126
- }
3127
- }
3128
-
3129
- if (scripts[i].getAttribute ("type") != null)
3130
- {
3131
- if (scripts[i].getAttribute ("type") !== "")
3132
- s.setAttribute ("type", scripts[i].getAttribute ("type"));
3133
- }
3134
-
3135
- s.innerHTML = scripts[i].innerHTML;
3136
-
3137
- if (hasSrc === false)
3138
- resolve2 ();
3139
- });
3140
- }
3141
- }
3142
-
3143
3229
  if (HotStaq.dispatchReadyEvents === true)
3144
3230
  {
3145
3231
  document.dispatchEvent (new Event ("DOMContentLoaded"));
@@ -3149,14 +3235,26 @@ export class HotStaq implements IHotStaq
3149
3235
  if (HotStaq.onReadyEvent != null)
3150
3236
  HotStaq.onReadyEvent (output);
3151
3237
 
3152
- // Fire onAfterNavigate hook.
3238
+ // Fire onAfterNavigate hook, then execute the new content's inline
3239
+ // scripts — unless the hook returned false, in which case the app is
3240
+ // running them itself and HotStaq stands down. Running them after the
3241
+ // hook lets the app prep the DOM first; running them at all is the
3242
+ // fix for inline page scripts silently failing on SPA navigation.
3243
+ let runScripts: boolean = true;
3244
+
3153
3245
  if (HotStaq.onAfterNavigate != null)
3154
3246
  {
3155
- let result = HotStaq.onAfterNavigate (path);
3247
+ let result: any = HotStaq.onAfterNavigate (path);
3156
3248
 
3157
3249
  if (result instanceof Promise)
3158
- await result;
3250
+ result = await result;
3251
+
3252
+ if (result === false)
3253
+ runScripts = false;
3159
3254
  }
3255
+
3256
+ if (runScripts === true)
3257
+ await HotStaq.executeInlineScripts (targetEl);
3160
3258
  }
3161
3259
 
3162
3260
  /**
@@ -296,7 +296,17 @@ export function registerComponent (tag: string, elementOptions: ElementDefinitio
296
296
 
297
297
  if (parentNode == null)
298
298
  throw new Error (`Unable to find document node with selector '${output.documentSelector}'`);
299
-
299
+
300
+ // Placing via documentSelector lands this element OUTSIDE
301
+ // its own <tag> (e.g. admin-edit appends its modal to
302
+ // <body>). On SPA navigation the tag upgrades again and
303
+ // would leave the previous copy behind as a duplicate-id
304
+ // orphan that id lookups resolve to instead of this fresh
305
+ // one — the root cause of stale-modal bugs that pages
306
+ // otherwise work around by rebinding. Clear the orphans
307
+ // before placing the new copy.
308
+ HotStaq.removeStaleDuplicateIds (compHtmlElement2);
309
+
300
310
  compHtmlElement2.parentElement.removeChild (compHtmlElement2);
301
311
  parentNode.appendChild (compHtmlElement2);
302
312