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