{"version":3,"file":"templateEditor.utils.mjs","names":[],"sources":["../../../../package/common/templateEditor/templateEditor.utils.ts"],"sourcesContent":["import type {\n  TemplateEditorLabels,\n  TemplateEditorToken,\n  TemplateEditorTokenGroup,\n  TemplateSegment,\n} from './templateEditor.types';\n\nexport const DEFAULT_PREFIX = '{{';\nexport const DEFAULT_SUFFIX = '}}';\n\nexport const DEFAULT_LABELS: Required<TemplateEditorLabels> = {\n  palette: 'Placeholders',\n  insertToken: 'Insert placeholder',\n  preview: 'Preview with sample data',\n  removeToken: (label) => `Remove ${label}`,\n  qualifiedToken: (group, label) => `${group} · ${label}`,\n  unknownToken: (id) => id,\n  unknownTokenTitle: (id) => `${id} (unknown placeholder)`,\n};\n\n/** Escapes a string so it can be embedded in a RegExp as a literal. */\nexport function escapeRegExp(value: string): string {\n  return value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n/**\n * Builds the token matcher from the configured delimiters.\n *\n * The id is deliberate on all four counts: non-greedy so it stops at the\n * first closing delimiter rather than swallowing everything up to the last one;\n * newline-free so a stray opening delimiter typed in prose can't consume whole\n * paragraphs; free of the opening delimiter, so an unclosed `{{` earlier on the\n * line stays text instead of taking that text and the next token into one chip;\n * and `+` rather than `*` so an empty `{{}}` stays literal text instead of\n * becoming an invisible, unremovable chip.\n */\nexport function buildTokenRegex(prefix: string, suffix: string): RegExp {\n  const open = escapeRegExp(prefix);\n  return new RegExp(`${open}((?:(?!${open})[^\\\\n])+?)${escapeRegExp(suffix)}`, 'g');\n}\n\n/**\n * Splits a template string into literal text and tokens, exactly as the editor reads it. Joining the\n * segments back, each token as `prefix + id + suffix`, gives the input unchanged.\n */\nexport function parseTemplate(value: string, prefix = DEFAULT_PREFIX, suffix = DEFAULT_SUFFIX): TemplateSegment[] {\n  if (!value) return [];\n  const segments: TemplateSegment[] = [];\n  const regex = buildTokenRegex(prefix, suffix);\n  let lastIndex = 0;\n  let match: RegExpExecArray | null;\n\n  while ((match = regex.exec(value)) !== null) {\n    if (match.index > lastIndex) {\n      segments.push({ type: 'text', text: value.slice(lastIndex, match.index) });\n    }\n    segments.push({ type: 'token', id: match[1] });\n    lastIndex = match.index + match[0].length;\n  }\n\n  if (lastIndex < value.length) {\n    segments.push({ type: 'text', text: value.slice(lastIndex) });\n  }\n  return segments;\n}\n\n/** Line endings as `\\n`, or, where the field is single-line, each run of line breaks as one space. */\nexport function normalizeLineBreaks(text: string, multiline: boolean): string {\n  return multiline ? text.replace(/\\r\\n?/g, '\\n') : text.replace(/(\\r\\n?|\\n)+/g, ' ');\n}\n\n/** Number of characters a token occupies in the serialized string, which is the unit caret offsets are counted in. */\nexport function tokenLength(id: string, prefix: string, suffix: string): number {\n  return prefix.length + id.length + suffix.length;\n}\n\n/** The span a token occupies in the serialized string. */\nexport interface TokenSpan {\n  start: number;\n  end: number;\n  id: string;\n}\n\n/** Every token in the string with the offsets it occupies. */\nexport function tokenSpans(value: string, prefix: string, suffix: string): TokenSpan[] {\n  const spans: TokenSpan[] = [];\n  let position = 0;\n  parseTemplate(value, prefix, suffix).forEach((segment) => {\n    const length = segment.type === 'text' ? segment.text.length : tokenLength(segment.id, prefix, suffix);\n    if (segment.type === 'token') spans.push({ start: position, end: position + length, id: segment.id });\n    position += length;\n  });\n  return spans;\n}\n\n/** The token immediately before `offset`, i.e. the one a backspace should remove whole. */\nexport function tokenSpanEndingAt(value: string, offset: number, prefix: string, suffix: string): TokenSpan | null {\n  return tokenSpans(value, prefix, suffix).find((span) => span.end === offset) ?? null;\n}\n\n/** The token immediately after `offset`, i.e. the one a forward delete should remove whole. */\nexport function tokenSpanStartingAt(value: string, offset: number, prefix: string, suffix: string): TokenSpan | null {\n  return tokenSpans(value, prefix, suffix).find((span) => span.start === offset) ?? null;\n}\n\n/** Whether `offset` starts a line with a token, which is where the editor keeps a spacer (see `isSpacer`). */\nexport function isLineStartBeforeToken(value: string, offset: number, prefix: string, suffix: string): boolean {\n  return offset > 0 && value[offset - 1] === '\\n' && !!tokenSpanStartingAt(value, offset, prefix, suffix);\n}\n\nconst WORD_BEFORE = /[\\p{L}\\p{M}\\p{N}]$/u;\nconst WORD_AFTER = /^[\\p{L}\\p{N}]/u;\n\n/**\n * The spaces smart spacing puts around a chip placed over `start…end`: one on each side where it\n * would otherwise touch a letter, a digit or another chip. Punctuation, whitespace and line edges\n * get none, so `({{name}})`, `{{name}}'s` and `{{first}}.{{last}}@acme.com` keep their shape: a\n * missing space is one keystroke to add, a wrong one inside an address breaks it unseen.\n */\nexport function spacesAround(\n  value: string,\n  start: number,\n  end: number,\n  prefix: string,\n  suffix: string,\n): [before: string, after: string] {\n  const before = WORD_BEFORE.test(value.slice(0, start)) || !!tokenSpanEndingAt(value, start, prefix, suffix);\n  const after = WORD_AFTER.test(value.slice(end)) || !!tokenSpanStartingAt(value, end, prefix, suffix);\n  return [before ? ' ' : '', after ? ' ' : ''];\n}\n\n/**\n * `value` with a chip's `start…end` cut out, and with smart spacing also the space that left\n * redundant: one of two spaces meeting in the gap, or one left at the start or end of a line.\n * `at` is where the cut closed, which is where the caret belongs.\n */\nexport function cutChip(value: string, start: number, end: number, tidy: boolean): { value: string; at: number } {\n  const without = value.slice(0, start) + value.slice(end);\n  if (!tidy) return { value: without, at: start };\n  const before = without[start - 1];\n  const after = without[start];\n  const lineStart = start === 0 || before === '\\n';\n  const lineEnd = start === without.length || after === '\\n';\n  const gap = before === ' ' && (after === ' ' || lineEnd) ? start - 1 : after === ' ' && lineStart ? start : null;\n  if (gap === null) return { value: without, at: start };\n  return { value: without.slice(0, gap) + without.slice(gap + 1), at: gap };\n}\n\n/** Longest query the autocomplete will track before giving up and assuming the delimiter was prose. */\nconst MAX_QUERY_LENGTH = 60;\n\n/**\n * The unterminated `prefix…` run the caret currently sits in, which is what\n * opens the autocomplete. Returns `null` when the caret is not inside one, so\n * a delimiter typed as ordinary prose stops matching as soon as it is followed\n * by a newline or a closing delimiter.\n */\nexport function openTokenRun(\n  value: string,\n  offset: number,\n  prefix: string,\n  suffix: string,\n): { start: number; query: string } | null {\n  const head = value.slice(0, offset);\n  const start = head.lastIndexOf(prefix);\n  // A delimiter of a complete token opens nothing. Where the suffix is the prefix, `%name%`, the\n  // token's closing `%` read as an open run, so every insertion reopened the list.\n  const closedUpTo = Math.max(\n    0,\n    ...tokenSpans(value, prefix, suffix).map((span) => (span.end <= offset ? span.end : 0)),\n  );\n  if (start < closedUpTo) return null;\n  const query = head.slice(start + prefix.length);\n  if (query.length > MAX_QUERY_LENGTH) return null;\n  if (query.includes('\\n') || query.includes(suffix)) return null;\n  return { start, query };\n}\n\n/** Flattens the groups into an id lookup. The first occurrence of an id wins. */\nexport function buildTokenIndex(groups: TemplateEditorTokenGroup[] = []): Map<string, TemplateEditorToken> {\n  const index = new Map<string, TemplateEditorToken>();\n  groups.forEach((group) => {\n    group.tokens?.forEach((token) => {\n      if (!index.has(token.id)) index.set(token.id, token);\n    });\n  });\n  return index;\n}\n\n/** The text shown on a chip and in the palette for a token. */\nexport function tokenLabel(token: TemplateEditorToken): string {\n  return token.label ?? token.id;\n}\n\n/**\n * Chip text per token id. A palette entry sits under its group's heading and a chip does not, so a\n * label two tokens share gets its group in front: two \"First name\" chips would read the same\n * whichever person they resolve to.\n */\nexport function buildChipLabels(\n  groups: TemplateEditorTokenGroup[] = [],\n  qualify: (group: string, label: string) => string,\n): Map<string, string> {\n  const index = buildTokenIndex(groups);\n  const counts = new Map<string, number>();\n  index.forEach((token) => counts.set(tokenLabel(token), (counts.get(tokenLabel(token)) ?? 0) + 1));\n  const labels = new Map<string, string>();\n  groups.forEach((group) => {\n    group.tokens?.forEach((token) => {\n      if (labels.has(token.id)) return;\n      const label = tokenLabel(token);\n      labels.set(token.id, group.label && (counts.get(label) ?? 0) > 1 ? qualify(group.label, label) : label);\n    });\n  });\n  return labels;\n}\n\n/** Case-insensitive substring filter over id, label, and description. Groups left empty are dropped. */\nexport function filterGroups(groups: TemplateEditorTokenGroup[] = [], query: string): TemplateEditorTokenGroup[] {\n  const needle = query.trim().toLowerCase();\n  if (!needle) return groups;\n  return groups\n    .map((group) => ({\n      ...group,\n      tokens: (group.tokens ?? []).filter((token) =>\n        [token.id, token.label, token.description].some((field) => field?.toLowerCase().includes(needle)),\n      ),\n    }))\n    .filter((group) => group.tokens.length > 0);\n}\n\n/**\n * `text` split around the first case-insensitive occurrence of `query`, for the autocomplete to mark\n * what matched; `null` when it does not occur. Matched with a regex rather than by `indexOf` on a\n * lowercased copy, whose offsets drift from the original where lowercasing changes a length (`İ`).\n */\nexport function splitAtMatch(text: string, query: string): [string, string, string] | null {\n  const needle = query.trim();\n  const match = needle ? new RegExp(escapeRegExp(needle), 'i').exec(text) : null;\n  if (!match) return null;\n  const end = match.index + match[0].length;\n  return [text.slice(0, match.index), match[0], text.slice(end)];\n}\n\n/** Every token across all groups, in display order. Used for autocomplete highlight navigation. */\nexport function flattenTokens(groups: TemplateEditorTokenGroup[] = []): TemplateEditorToken[] {\n  return groups.flatMap((group) => group.tokens ?? []);\n}\n"],"mappings":"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"}