@atlaskit/editor-plugin-autocomplete 3.0.0 → 3.1.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.
@@ -194,16 +194,15 @@ function applyGrammarFilter(
194
194
  }
195
195
  }
196
196
 
197
- const finalFiltered = filtered.length > 0 ? filtered : candidates;
198
-
197
+ // Grammar is authoritative.
199
198
  return {
200
- filtered: finalFiltered,
199
+ filtered: filtered,
201
200
  grammarMeta: {
202
201
  prevWord: lowerPrev,
203
202
  prevTags,
204
203
  before: candidates.length,
205
- after: finalFiltered.length,
206
- dropped: filtered.length > 0 ? dropped : [],
204
+ after: filtered.length,
205
+ dropped: dropped,
207
206
  },
208
207
  };
209
208
  }
@@ -17,11 +17,9 @@
17
17
 
18
18
  import { EXPERIENCE_NAME, failExp, startExp, succeedExp } from '../analytics/ufo';
19
19
 
20
- // import bigramsData from './data/bigrams.json';
21
- import l3VocabularyData from './data/l3_vocabulary.json';
22
- import vocabularyData from './data/vocabulary_10k.json';
23
- import wordIndexData from './data/word_index_10k.json';
24
- // import { rankCandidates, isGrammarAllowed } from './scoring-pipeline';
20
+ // The vocabulary, L3 and word-index JSON payloads are dynamically imported in
21
+ // loadDefaultVocabulary / loadVectorsAsync so their (large) contents stay out of
22
+ // the editor's main chunk and only load when autocomplete is initialised.
25
23
  import { isAutocompleteDebugEnabled } from './debug-mode';
26
24
  import { rankCandidates, STAGE1_WEIGHT, STAGE2_WEIGHT, MIN_STAGE1_SCORE } from './scoring-pipeline';
27
25
  import type { ScoringCandidate } from './scoring-pipeline';
@@ -35,7 +33,7 @@ const PUNCTUATION_BOUNDARY_REGEX = /^[.,;:!?()\[\]{}"'`]+|[.,;:!?()\[\]{}"'`]+$/
35
33
  const MIN_PREFIX_LENGTH = 3;
36
34
  const MAX_CANDIDATES = 200;
37
35
  const CONTEXT_WORDS = 10;
38
- const MIN_SCORE_THRESHOLD = 0.2;
36
+ const MIN_SCORE_THRESHOLD = 0.35;
39
37
  const L3_BASELINE_FREQ = 0.001;
40
38
 
41
39
  // ─── Types ───────────────────────────────────────────────────────────────────
@@ -175,7 +173,6 @@ const wordTrie = new WeightedWordTrie();
175
173
  // L3 Trie (General English Fallback)
176
174
  const l3Trie = new WeightedWordTrie();
177
175
 
178
- // --- Initialization Function ---
179
176
  /**
180
177
  * Loads the General English vocabulary.
181
178
  * expects a simple array of strings: ["about", "above", "actually", ...]
@@ -290,14 +287,11 @@ const tokenize = (text: string): string[] => {
290
287
  };
291
288
 
292
289
  const extractPreviousWord = (text: string): string => {
293
- // 1. Split the text by newlines or punctuation (. ? !)
290
+ // Only consider the current sentence/line the user is typing in.
294
291
  // eslint-disable-next-line require-unicode-regexp
295
292
  const sentences = text.split(/[\n.?!]+/);
296
-
297
- // 2. Only look at the current sentence/line the user is typing in
298
293
  const currentSentence = sentences[sentences.length - 1];
299
294
 
300
- // 3. Extract the previous word as normal
301
295
  // eslint-disable-next-line require-unicode-regexp
302
296
  const words = currentSentence.trimEnd().split(/\s+/);
303
297
  return words.length >= 2 ? words[words.length - 2] : '';
@@ -370,7 +364,6 @@ export const incrementSessionFreq = (word: string): void => {
370
364
  * Pass `undefined` (or omit the argument) to skip priming — useful when the
371
365
  * calling context does not yet have a page value available.
372
366
  */
373
- // NOTE: We ingest full page context here
374
367
  export const ingestDocumentPage = (pageContent: string | undefined): void => {
375
368
  if (!pageContent) {
376
369
  return;
@@ -402,7 +395,11 @@ export const ingestDocumentPage = (pageContent: string | undefined): void => {
402
395
 
403
396
  export const predict = (textBefore: string): string | null => {
404
397
  if (!isInitialized) {
405
- loadDefaultVocabulary();
398
+ // Vocabulary JSON is code-split and loads asynchronously. Kick off the load
399
+ // and skip this keystroke; the plugin also primes it on focus, so the tries
400
+ // are usually ready before the user types.
401
+ void loadDefaultVocabulary().catch(() => {});
402
+ return null;
406
403
  }
407
404
 
408
405
  const t0 = performance.now();
@@ -458,19 +455,17 @@ export const predict = (textBefore: string): string | null => {
458
455
  return null;
459
456
  }
460
457
 
461
- // 1. Primary Query: Ask the L2 Domain Trie
462
458
  const candidates = wordTrie.getCandidates(currentWord, MAX_CANDIDATES);
463
459
 
464
- // 2. Fallback Query: Gap-fill with the L3 General English Trie
460
+ // Gap-fill from the L3 general-English trie, requesting a full buffer so
461
+ // enough survive de-duplication against the L2 results.
465
462
  if (candidates.length < MAX_CANDIDATES) {
466
- // Ask L3 for MAX_CANDIDATES to guarantee we have enough buffer
467
- // to survive the deduplication process.
468
463
  const l3Candidates = l3Trie.getCandidates(currentWord, MAX_CANDIDATES);
469
464
 
470
465
  const existingWords = new Set(candidates.map((c) => c.word));
471
466
 
472
467
  for (const l3c of l3Candidates) {
473
- if (candidates.length >= MAX_CANDIDATES) break; // Stop exactly at the limit
468
+ if (candidates.length >= MAX_CANDIDATES) break;
474
469
 
475
470
  if (!existingWords.has(l3c.word)) {
476
471
  candidates.push(l3c);
@@ -711,6 +706,45 @@ interface VocabularyJson {
711
706
  >;
712
707
  }
713
708
 
709
+ /**
710
+ * Unwrap a dynamically imported JSON module to its parsed value, handling both
711
+ * interop modes AFM's bundler chain emits: a `.default`-wrapped namespace
712
+ * (classic webpack) and a named-exports namespace (webpack 5 / atlaspack JSON
713
+ * modules, where `default` can be a misleading scalar). Named exports are
714
+ * preferred when present. The caller declares the JSON `shape` because a dense
715
+ * array and a sparse numeric-keyed object are emitted identically as named
716
+ * exports. Kept in lock-step with the matching helper in local-slow-lane-client.ts.
717
+ */
718
+ const unwrapJsonModule = <T>(mod: unknown, shape: 'object' | 'array'): T | null => {
719
+ if (mod == null || typeof mod !== 'object') {
720
+ return null;
721
+ }
722
+ const namespace = mod as Record<string, unknown> & { default?: unknown };
723
+ const ownKeys = Object.keys(namespace).filter((k) => k !== 'default' && k !== '__esModule');
724
+
725
+ if (ownKeys.length > 0) {
726
+ if (shape === 'array') {
727
+ const len = ownKeys.length;
728
+ const arr = new Array(len);
729
+ for (let i = 0; i < len; i++) {
730
+ arr[i] = namespace[String(i)];
731
+ }
732
+ return arr as T;
733
+ }
734
+ const obj: Record<string, unknown> = {};
735
+ for (const k of ownKeys) {
736
+ obj[k] = namespace[k];
737
+ }
738
+ return obj as T;
739
+ }
740
+
741
+ if ('default' in namespace && namespace.default != null) {
742
+ return namespace.default as T;
743
+ }
744
+
745
+ return null;
746
+ };
747
+
714
748
  export const loadVectorsAsync = async (options?: {
715
749
  getBinaryUrl?: () => Promise<string>;
716
750
  }): Promise<void> => {
@@ -752,8 +786,25 @@ export const loadVectorsAsync = async (options?: {
752
786
  }
753
787
  const buffer = await res.arrayBuffer();
754
788
  const float32 = new Float32Array(buffer);
755
- const wordIndex = wordIndexData as Record<string, number>;
789
+
790
+ // word_index_10k.json is wrapped as `{ "index": {…} }` so no real entry
791
+ // (e.g. the word "default") can shadow the synthetic ESM `default` export
792
+ // the bundler creates for dynamically-imported JSON.
793
+ const wordIndexModule = await import(
794
+ /* webpackChunkName: "@atlaskit-internal_editor-plugin-autocomplete-word-index-10k" */ './data/word_index_10k.json'
795
+ );
796
+ const wordIndexOuter = unwrapJsonModule<{ index?: Record<string, number> }>(
797
+ wordIndexModule,
798
+ 'object',
799
+ );
800
+ const wordIndex = wordIndexOuter?.index ?? {};
756
801
  const nWords = Object.keys(wordIndex).length;
802
+ if (nWords === 0) {
803
+ // eslint-disable-next-line no-console
804
+ console.warn(
805
+ '[text-predictor] word_index_10k.json missing its `index` wrapper — wordIndex is empty, semantic scoring will be a no-op.',
806
+ );
807
+ }
757
808
  const dim = float32.length / nWords;
758
809
 
759
810
  vectorStore = { float32, wordIndex, dim };
@@ -782,34 +833,62 @@ export const initVectors = (store: VectorStore): void => {
782
833
  vectorStore = store;
783
834
  };
784
835
 
785
- export const loadDefaultVocabulary = (): void => {
836
+ let vocabularyLoadPromise: Promise<void> | undefined;
837
+
838
+ export const loadDefaultVocabulary = (): Promise<void> => {
786
839
  if (isInitialized) {
787
- return;
840
+ return Promise.resolve();
841
+ }
842
+ if (vocabularyLoadPromise) {
843
+ return vocabularyLoadPromise;
788
844
  }
789
845
 
790
- startExp(EXPERIENCE_NAME.LOAD_VOCABULARY, 'singleton');
846
+ vocabularyLoadPromise = (async () => {
847
+ startExp(EXPERIENCE_NAME.LOAD_VOCABULARY, 'singleton');
791
848
 
792
- try {
793
- // 1. Load the Atlassian Domain (L2)
794
- const data = vocabularyData as VocabularyJson;
795
- const terms = Object.entries(data.words).map(([word, stats]) => ({
796
- word,
797
- freq: stats.freq,
798
- docFreq: stats.doc_freq,
799
- authorFreq: stats.author_freq,
800
- }));
801
- initVocabulary({ terms });
802
-
803
- // 2. Load General English (L3)
804
- const l3Words = l3VocabularyData as string[];
805
- initL3Vocabulary(l3Words);
806
-
807
- succeedExp(EXPERIENCE_NAME.LOAD_VOCABULARY, 'singleton', {
808
- l2WordCount: terms.length,
809
- l3WordCount: l3Words.length,
810
- });
811
- } catch (e) {
812
- failExp(EXPERIENCE_NAME.LOAD_VOCABULARY, 'singleton', { errorType: 'parse_error' });
813
- throw e;
814
- }
849
+ try {
850
+ // The L2 vocabulary and L3 word list are code-split into their own async
851
+ // chunks so they stay out of the editor's main bundle.
852
+ const [vocabularyModule, l3VocabularyModule] = await Promise.all([
853
+ import(
854
+ /* webpackChunkName: "@atlaskit-internal_editor-plugin-autocomplete-vocabulary-10k" */ './data/vocabulary_10k.json'
855
+ ),
856
+ import(
857
+ /* webpackChunkName: "@atlaskit-internal_editor-plugin-autocomplete-l3-vocabulary" */ './data/l3_vocabulary.json'
858
+ ),
859
+ ]);
860
+
861
+ const vocabularyData = unwrapJsonModule<VocabularyJson>(vocabularyModule, 'object');
862
+ const l3VocabularyData = unwrapJsonModule<string[]>(l3VocabularyModule, 'array');
863
+
864
+ if (vocabularyData?.words == null || !Array.isArray(l3VocabularyData)) {
865
+ throw new Error('[text-predictor] vocabulary JSON modules could not be unwrapped');
866
+ }
867
+
868
+ const terms = Object.entries(vocabularyData.words).map(([word, stats]) => ({
869
+ word,
870
+ freq: stats.freq,
871
+ docFreq: stats.doc_freq,
872
+ authorFreq: stats.author_freq,
873
+ }));
874
+
875
+ // Load L3 before L2: initVocabulary flips `isInitialized = true`, so it
876
+ // must run last — otherwise a throw in initL3Vocabulary would strand
877
+ // `isInitialized` true and the retry path could never reload L3.
878
+ initL3Vocabulary(l3VocabularyData);
879
+ initVocabulary({ terms });
880
+
881
+ succeedExp(EXPERIENCE_NAME.LOAD_VOCABULARY, 'singleton', {
882
+ l2WordCount: terms.length,
883
+ l3WordCount: l3VocabularyData.length,
884
+ });
885
+ } catch (e) {
886
+ failExp(EXPERIENCE_NAME.LOAD_VOCABULARY, 'singleton', { errorType: 'parse_error' });
887
+ // Allow a later call to retry the load rather than caching the failure.
888
+ vocabularyLoadPromise = undefined;
889
+ throw e;
890
+ }
891
+ })();
892
+
893
+ return vocabularyLoadPromise;
815
894
  };