@docentjs/core 0.1.0 → 0.2.0

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/dist/index.js CHANGED
@@ -665,6 +665,344 @@ var TourController = class {
665
665
  }
666
666
  };
667
667
  //#endregion
668
- export { ANONYMOUS_IDENTITY, IDLE_STATE, NOOP_SINK, ProgressStore, SCHEMA_VERSION, STORAGE_PREFIX, TourController, canGoBack, combineSinks, createEvent, createMemoryStorage, defineTour, evaluateAll, evaluateCondition, evaluateTrait, findEligible, hasNext, isActive, isFinished, matchRoute, progress, reduce, resolveStepIndex, shouldShow, storageKey, tourVersion };
668
+ //#region src/manager/scoped-storage.ts
669
+ /**
670
+ * Prefix every key with the user id so progress on a shared browser (or a
671
+ * developer switching test accounts) never leaks between users.
672
+ * Anonymous visitors use the unprefixed keys.
673
+ */
674
+ function scopeStorage(base, userId) {
675
+ if (!userId) return base;
676
+ const prefix = `u:${userId}:`;
677
+ return {
678
+ get: (key) => base.get(prefix + key),
679
+ set: (key, value) => base.set(prefix + key, value),
680
+ remove: (key) => base.remove(prefix + key)
681
+ };
682
+ }
683
+ //#endregion
684
+ //#region src/manager/docent.ts
685
+ /**
686
+ * The tour manager. Holds many tours, watches their triggers, checks
687
+ * conditions and frequency, and starts at most one at a time. This is what
688
+ * turns the rules in the tour JSON into behaviour.
689
+ */
690
+ function isTourSource(value) {
691
+ return !!value && !Array.isArray(value) && typeof value.load === "function";
692
+ }
693
+ var Docent = class {
694
+ /** Resolves once tours and progress are loaded and triggers are armed. */
695
+ ready;
696
+ options;
697
+ env;
698
+ baseStorage;
699
+ identity;
700
+ store;
701
+ tours = /* @__PURE__ */ new Map();
702
+ records = /* @__PURE__ */ new Map();
703
+ controller;
704
+ activeId = null;
705
+ /** Tours whose trigger fired while another tour was running. */
706
+ queue = [];
707
+ triggerCleanups = [];
708
+ /** `auto` triggers fire once per manager instance (per page load), not on every re-arm. */
709
+ autoFired = /* @__PURE__ */ new Set();
710
+ cleanups = [];
711
+ timers = /* @__PURE__ */ new Set();
712
+ listeners = /* @__PURE__ */ new Set();
713
+ destroyed = false;
714
+ constructor(options) {
715
+ this.options = options;
716
+ this.env = options.environment;
717
+ this.identity = options.identity ?? ANONYMOUS_IDENTITY;
718
+ this.baseStorage = options.storage ?? createMemoryStorage();
719
+ this.store = new ProgressStore(scopeStorage(this.baseStorage, this.identity.id));
720
+ this.ready = this.init();
721
+ }
722
+ getState() {
723
+ return {
724
+ active: this.activeId,
725
+ tours: [...this.tours.keys()]
726
+ };
727
+ }
728
+ subscribe(listener) {
729
+ this.listeners.add(listener);
730
+ return () => this.listeners.delete(listener);
731
+ }
732
+ /** The controller of the running tour, for fine-grained control. */
733
+ get activeController() {
734
+ return this.controller;
735
+ }
736
+ /**
737
+ * Set who the user is. Progress is stored per user id, and traits feed
738
+ * `trait` conditions. Re-evaluates triggers, since the user may now qualify.
739
+ */
740
+ async identify(id, traits = {}) {
741
+ await this.ready;
742
+ const userChanged = id !== this.identity.id;
743
+ this.identity = id === void 0 ? { traits } : {
744
+ id,
745
+ traits
746
+ };
747
+ if (userChanged) {
748
+ this.autoFired.clear();
749
+ this.store = new ProgressStore(scopeStorage(this.baseStorage, id));
750
+ await this.loadRecords();
751
+ }
752
+ this.armTriggers();
753
+ }
754
+ /**
755
+ * Report something that happened in your app. Starts tours with a matching
756
+ * `event` trigger and advances a running step waiting on that event.
757
+ */
758
+ track(eventName) {
759
+ this.controller?.notify(eventName);
760
+ for (const tour of this.tours.values()) {
761
+ const t = tour.trigger;
762
+ if (t?.type === "event" && t.name === eventName) this.fire(tour.id);
763
+ }
764
+ }
765
+ /**
766
+ * Start a tour now, ignoring its trigger, conditions and frequency. Use for
767
+ * "take the tour" buttons. Stops any tour already running.
768
+ */
769
+ async start(tourId, options = {}) {
770
+ await this.ready;
771
+ const tour = this.tours.get(tourId);
772
+ if (!tour) return false;
773
+ await this.stopActive();
774
+ await this.run(tour, options.at, true);
775
+ return true;
776
+ }
777
+ /** Whether a tour's conditions hold and its frequency allows showing it now. */
778
+ isEligible(tourId) {
779
+ const tour = this.tours.get(tourId);
780
+ if (!tour) return false;
781
+ return shouldShow(tour, this.records.get(tourId) ?? null) && evaluateAll(tour.conditions, this.conditionEnv());
782
+ }
783
+ /** Progress state of a tour for the current user. */
784
+ tourState(tourId) {
785
+ const tour = this.tours.get(tourId);
786
+ const record = this.records.get(tourId);
787
+ if (!record) return "not-started";
788
+ if (tour && record.version < tourVersion(tour)) return "not-started";
789
+ return record.state;
790
+ }
791
+ /** Forget progress for one tour, or all of them, so they show again. */
792
+ async reset(tourId) {
793
+ await this.ready;
794
+ const ids = tourId ? [tourId] : [...this.tours.keys()];
795
+ for (const id of ids) {
796
+ await this.store.clear(id);
797
+ this.records.set(id, null);
798
+ this.autoFired.delete(id);
799
+ }
800
+ this.armTriggers();
801
+ }
802
+ /**
803
+ * Re-check triggers and tell the running tour the route may have changed.
804
+ * Call after navigation if your router does not emit browser navigation events.
805
+ */
806
+ async refresh() {
807
+ await this.ready;
808
+ this.armTriggers();
809
+ await this.controller?.routeChanged();
810
+ }
811
+ /** Stop the running tour (recorded as skipped). */
812
+ async stop() {
813
+ await this.controller?.skip();
814
+ }
815
+ async destroy() {
816
+ this.destroyed = true;
817
+ this.disarmTriggers();
818
+ for (const c of this.cleanups) c();
819
+ this.cleanups.length = 0;
820
+ const controller = this.controller;
821
+ this.controller = void 0;
822
+ this.activeId = null;
823
+ await controller?.destroy();
824
+ this.listeners.clear();
825
+ }
826
+ async init() {
827
+ const source = this.options.tours;
828
+ const initial = isTourSource(source) ? await source.load() : source ?? [];
829
+ this.setTours(initial);
830
+ await this.loadRecords();
831
+ if (this.destroyed) return;
832
+ if (isTourSource(source) && source.subscribe) this.cleanups.push(source.subscribe((tours) => {
833
+ this.setTours(tours);
834
+ this.loadRecords().then(() => this.armTriggers());
835
+ }));
836
+ if (this.env.onRouteChange) this.cleanups.push(this.env.onRouteChange(() => this.armTriggers()));
837
+ this.armTriggers();
838
+ }
839
+ setTours(tours) {
840
+ this.tours = new Map(tours.map((t) => [t.id, t]));
841
+ this.queue = this.queue.filter((id) => this.tours.has(id));
842
+ this.emitState();
843
+ }
844
+ async loadRecords() {
845
+ const entries = await Promise.all([...this.tours.keys()].map(async (id) => [id, await this.store.get(id)]));
846
+ this.records = new Map(entries);
847
+ }
848
+ disarmTriggers() {
849
+ for (const c of this.triggerCleanups) c();
850
+ this.triggerCleanups = [];
851
+ for (const t of this.timers) clearTimeout(t);
852
+ this.timers.clear();
853
+ }
854
+ /**
855
+ * (Re)arm every trigger. Cheap; called on load, identify, route change,
856
+ * source updates and after a tour finishes (so chained tours can start).
857
+ * `except` skips one tour, used for the tour that just finished.
858
+ */
859
+ armTriggers(except) {
860
+ if (this.destroyed) return;
861
+ this.disarmTriggers();
862
+ for (const tour of this.tours.values()) {
863
+ const trigger = tour.trigger;
864
+ if (!trigger || tour.id === except) continue;
865
+ this.arm(tour, trigger);
866
+ }
867
+ }
868
+ arm(tour, trigger) {
869
+ switch (trigger.type) {
870
+ case "manual":
871
+ case "event": return;
872
+ case "auto":
873
+ if (this.autoFired.has(tour.id)) return;
874
+ this.fireAfter(tour.id, trigger.delay);
875
+ return;
876
+ case "route": {
877
+ const route = this.env.currentRoute?.();
878
+ if (route !== void 0 && matchRoute(trigger.pattern, route)) this.fireAfter(tour.id, trigger.delay);
879
+ return;
880
+ }
881
+ case "element":
882
+ if (!this.env.watchTarget) {
883
+ if (this.env.hasTarget(trigger.target)) this.fireAfter(tour.id, trigger.delay);
884
+ return;
885
+ }
886
+ this.triggerCleanups.push(this.env.watchTarget(trigger.target, () => this.fireAfter(tour.id, trigger.delay)));
887
+ }
888
+ }
889
+ fireAfter(tourId, delay) {
890
+ if (!delay) {
891
+ this.fire(tourId);
892
+ return;
893
+ }
894
+ const timer = setTimeout(() => {
895
+ this.timers.delete(timer);
896
+ this.fire(tourId);
897
+ }, delay);
898
+ this.timers.add(timer);
899
+ }
900
+ /** A trigger fired: start the tour if eligible, or queue it behind the running one. */
901
+ fire(tourId) {
902
+ if (this.destroyed || tourId === this.activeId) return;
903
+ if (!this.isEligible(tourId) || !this.triggerStillHolds(tourId)) return;
904
+ if (this.tours.get(tourId)?.trigger?.type === "auto") this.autoFired.add(tourId);
905
+ if (this.activeId) {
906
+ if (!this.queue.includes(tourId)) this.queue.push(tourId);
907
+ return;
908
+ }
909
+ const tour = this.tours.get(tourId);
910
+ if (tour) this.run(tour, void 0, false);
911
+ }
912
+ /** Route triggers are only valid while the user is still on a matching route. */
913
+ triggerStillHolds(tourId) {
914
+ const trigger = this.tours.get(tourId)?.trigger;
915
+ if (trigger?.type !== "route") return true;
916
+ const route = this.env.currentRoute?.();
917
+ return route !== void 0 && matchRoute(trigger.pattern, route);
918
+ }
919
+ conditionEnv() {
920
+ const env = {
921
+ identity: this.identity,
922
+ elementExists: (t) => this.env.hasTarget(t),
923
+ tourState: (id) => this.tourState(id),
924
+ custom: this.options.custom ?? {}
925
+ };
926
+ const route = this.env.currentRoute?.();
927
+ if (route !== void 0) env.route = route;
928
+ return env;
929
+ }
930
+ sharedOptions(tour) {
931
+ const shared = {
932
+ identity: this.identity,
933
+ storage: scopeStorage(this.baseStorage, this.identity.id),
934
+ tourState: (id) => this.tourState(id)
935
+ };
936
+ if (this.options.sink) shared.sink = this.options.sink;
937
+ const hooks = this.options.hooks?.[tour.id];
938
+ if (hooks) shared.hooks = hooks;
939
+ if (this.options.custom) shared.custom = this.options.custom;
940
+ if (this.options.now) shared.now = this.options.now;
941
+ return shared;
942
+ }
943
+ async run(tour, at, manual) {
944
+ this.queue = this.queue.filter((id) => id !== tour.id);
945
+ const controller = this.options.createController(tour, this.sharedOptions(tour));
946
+ this.controller = controller;
947
+ this.activeId = tour.id;
948
+ this.emitState();
949
+ const off = controller.subscribe((state) => {
950
+ if (state.status === "completed" || state.status === "skipped" || state.status === "aborted") {
951
+ off();
952
+ this.finished(controller, tour, state.status === "completed" ? "completed" : "skipped");
953
+ }
954
+ });
955
+ if (!manual && at === void 0 && tour.options?.persist) await controller.resume();
956
+ else await controller.start(at);
957
+ if (controller.getState().status === "idle" && this.controller === controller) {
958
+ off();
959
+ this.release(controller);
960
+ }
961
+ }
962
+ /**
963
+ * The controller emits the final status before it finishes writing storage,
964
+ * so record the outcome here directly instead of reading it back.
965
+ */
966
+ finished(controller, tour, state) {
967
+ if (this.controller !== controller) return;
968
+ this.records.set(tour.id, {
969
+ tourId: tour.id,
970
+ version: tourVersion(tour),
971
+ state,
972
+ updatedAt: (this.options.now ?? Date.now)()
973
+ });
974
+ this.release(controller, tour.id);
975
+ }
976
+ release(controller, finishedId) {
977
+ this.controller = void 0;
978
+ this.activeId = null;
979
+ this.emitState();
980
+ setTimeout(() => {
981
+ controller.destroy();
982
+ this.drainQueue();
983
+ if (!this.activeId) this.armTriggers(finishedId);
984
+ }, 0);
985
+ }
986
+ /** Stop the running tour without recording an outcome (used before a manual start). */
987
+ async stopActive() {
988
+ const controller = this.controller;
989
+ if (!controller) return;
990
+ this.controller = void 0;
991
+ this.activeId = null;
992
+ await controller.destroy();
993
+ }
994
+ drainQueue() {
995
+ while (this.queue.length > 0 && !this.activeId) {
996
+ const next = this.queue.shift();
997
+ if (next) this.fire(next);
998
+ }
999
+ }
1000
+ emitState() {
1001
+ const state = this.getState();
1002
+ for (const l of this.listeners) l(state);
1003
+ }
1004
+ };
1005
+ //#endregion
1006
+ export { ANONYMOUS_IDENTITY, Docent, IDLE_STATE, NOOP_SINK, ProgressStore, SCHEMA_VERSION, STORAGE_PREFIX, TourController, canGoBack, combineSinks, createEvent, createMemoryStorage, defineTour, evaluateAll, evaluateCondition, evaluateTrait, findEligible, hasNext, isActive, isFinished, matchRoute, progress, reduce, resolveStepIndex, scopeStorage, shouldShow, storageKey, tourVersion };
669
1007
 
670
1008
  //# sourceMappingURL=index.js.map