1// utils/slugUtil.js 2(function (root, factory) { 3 if (typeof define === 'function' && define.amd) { 4 define([], factory); 5 } else if (typeof module === 'object' && module.exports) { 6 module.exports = factory(); 7 } else { 8 root.slugUtil = factory(); 9 } 10}(typeof self !== 'undefined' ? self : this, function () { 11 'use strict'; 12 13 /** 14 * @typedef {Object} PathautoSettings 15 * @property {string[]|string} [ignore_words=[]] // Accepts array or comma-separated string 16 * @property {Object.<string,('remove'|'replace'|0|1|string|number)>} [punctuation={}] 17 * @property {number} [max_length=128] 18 * @property {string} [separator='-'] 19 * @property {boolean} [lowercase=true] 20 * @property {boolean} [transliterate=false] 21 */ 22 23 /** 24 * @typedef {Object} GenerateOptions 25 * @property {PathautoSettings} [settings] // Per-call override of config 26 */ 27 28 // Internal configuration state (optional, set via configure()). 29 let internalConfig = null; 30 31 // ---- Helpers ---- 32 33 /** 34 * Escape a string for safe usage inside RegExp. 35 * @param {string} s 36 * @returns {string} 37 */ 38 function escapeRegExp(s) { 39 return (s || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 40 } 41 42 /** 43 * Minimal transliteration using Unicode NFD stripping combining diacritics. 44 * Note: For full Pathauto parity, use a comprehensive map or a dedicated lib. 45 * @param {string} s 46 * @returns {string} 47 */ 48 function lightTransliterate(s) { 49 if (!s) return s; 50 try { 51 return s.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); 52 } catch (e) { 53 // Older browsers without normalize() 54 return s; 55 } 56 } 57 58 /** 59 * Normalize ignore_words coming from Drupal: supports array or comma-separated string. 60 * @param {unknown} v 61 * @returns {string[]} 62 */ 63 function normalizeIgnoreWords(v) { 64 if (Array.isArray(v)) { 65 return v 66 .flatMap(item => String(item ?? '').split(',')) 67 .map(s => s.trim()) 68 .filter(Boolean); 69 } 70 if (typeof v === 'string') { 71 return v 72 .split(',') 73 .map(s => s.trim()) 74 .filter(Boolean); 75 } 76 return []; 77 } 78 79 // Pathauto-style punctuation name â literal char(s) 80 /** @type {Record<string, string|string[]>} */ 81 const PATHAUTO_PUNCT_MAP = { 82 // brackets & parens 83 'left-square-bracket': '[', 84 'right-square-bracket': ']', 85 'left-parenthesis': '(', 86 'right-parenthesis': ')', 87 'left-curly-brace': '{', 88 'right-curly-brace': '}', 89 90 // dashes / hyphen 91 'hyphen': '-', 92 'en-dash': 'â', 93 'em-dash': 'â', 94 95 // quotes 96 'apostrophe': "'", 97 'single-quote': "'", 98 'double-quote': '"', 99 'quotation-mark': '"', 100 101 // slashes & backslash 102 'slash': '/', 103 'backslash': '\\', 104 105 // punctuation common 106 'colon': ':', 107 'semicolon': ';', 108 'comma': ',', 109 'period': '.', 110 'dot': '.', 111 'underscore': '_', 112 'question-mark': '?', 113 'exclamation-mark': '!', 114 'asterisk': '*', 115 'plus': '+', 116 'equals': '=', 117 'hash': '#', 118 'percent': '%', 119 'caret': '^', 120 'pipe': '|', 121 'tilde': '~', 122 'grave-accent': '`', 123 'ampersand': '&', 124 'at-sign': '@', 125 126 // extras you may see 127 'angle-left': '<', 128 'angle-right': '>', 129 'ellipsis': 'â¦', 130 }; 131 132 /** 133 * Translate a Pathauto-style punctuation map into { [char]: 'remove'|'replace' }. 134 * Accepts char keys as-is; converts named keys via PATHAUTO_PUNCT_MAP. 135 * 0/'0'/'remove' => 'remove' 136 * 1/'1'/'replace' => 'replace' 137 * @param {Record<string, 'remove'|'replace'|0|1|string|number>} src 138 * @returns {Record<string, 'remove'|'replace'>} 139 */ 140 function normalizePunctuationMap(src) { 141 const out = {}; 142 for (const [key, action] of Object.entries(src || {})) { 143 const normalized = 144 action === 'remove' || action === 0 || action === '0' 145 ? 'remove' 146 : action === 'replace' || action === 1 || action === '1' 147 ? 'replace' 148 : null; 149 150 if (!normalized) continue; 151 152 const mapped = PATHAUTO_PUNCT_MAP[key]; 153 if (mapped) { 154 if (Array.isArray(mapped)) { 155 for (const ch of mapped) out[ch] = normalized; 156 } else { 157 out[mapped] = normalized; 158 } 159 } else { 160 // Assume key is a literal char (or multi-char token). 161 if (key) out[key] = normalized; 162 } 163 } 164 return out; 165 } 166 167 /** 168 * Resolve effective configuration in this order: 169 * 1) Per-call `override`
170 * 2) Internal configured state (set via configure()) 171 * 3) drupalSettings.mymodule.pathauto (if available on window) 172 * 4) Safe defaults 173 * @param {PathautoSettings} [override] 174 * @returns {Required<PathautoSettings>} 175 */ 176 function resolveConfig(override) { 177 // Pull from `drupalSettings` if present (browser, Drupal) 178 let fromDrupalSettings = null; 179 try { 180 // eslint-disable-next-line no-undef 181 if (typeof drupalSettings !== 'undefined' && drupalSettings?.pathauto) { 182 // eslint-disable-next-line no-undef 183 fromDrupalSettings = drupalSettings.pathauto; 184 } 185 console.log(fromDrupalSettings); 186 } catch (e) { 187 // Ignore environment without drupalSettings. 188 } 189 190 const base = override || internalConfig || fromDrupalSettings || {}; 191 192 return /** @type {Required<PathautoSettings>} */ ({ 193 ignore_words: normalizeIgnoreWords(base.ignore_words), 194 punctuation: base && typeof base.punctuation === 'object' && base.punctuation !== null ? base.punctuation : {}, 195 max_length: Number.isFinite(base.max_length) ? base.max_length : 128, 196 separator: typeof base.separator === 'string' && base.separator.length ? base.separator : '-', 197 lowercase: typeof base.lowercase === 'boolean' ? base.lowercase : true, 198 transliterate: !!base.transliterate, 199 }); 200 } 201 202 /** 203 * Configure global/internal settings once (or multiple times). 204 * @param {PathautoSettings} settings 205 */ 206 function configure(settings) { 207 internalConfig = resolveConfig(settings); 208 } 209 210 /** 211 * Return the currently effective config (no override). 212 * @returns {Required<PathautoSettings>} 213 */ 214 function getConfig() { 215 return resolveConfig(); 216 } 217 218 /** 219 * Generate a Pathauto-like slug from id + name. 220 * - Handles ignore words (array or comma-separated string) 221 * - Punctuation rules ('remove'/'replace' or 0/1) 222 * - Lowercasing (optional) 223 * - Separator collapsing/trim 224 * - Max length with graceful cutback to last separator 225 * - Light transliteration (diacritics stripping) when enabled 226 * 227 * @param {string|number} id 228 * @param {string} name 229 * @param {GenerateOptions} [options] 230 * @returns {string} 231 */ 232 function generateSlug(id, name, options) { 233 // NOTE: per-call overrides come from options.settings 234 const cfg = resolveConfig(options?.settings); 235 236 const separator = cfg.separator; 237 const lowercase = cfg.lowercase; 238 const maxLen = cfg.max_length; 239 240 let slug = `${id ?? ''} ${name ?? ''}`.trim(); 241 242 if (cfg.transliterate) { 243 slug = lightTransliterate(slug); 244 } 245 246 // A) Remove ignore words (replace with space to preserve boundaries). 247 if (cfg.ignore_words.length) { 248 const words = cfg.ignore_words 249 .map(w => (w == null ? '' : String(w).trim().toLowerCase())) 250 .filter(Boolean); 251 252 if (words.length) { 253 const escaped = words.map(escapeRegExp); 254 const re = new RegExp(`\\b(${escaped.join('|')})\\b`, 'gi'); 255 slug = slug.replace(re, ' '); 256 } 257 } 258 259 // B) Punctuation rules: 'remove'/'replace' or 0/1 260 const punct = normalizePunctuationMap(cfg.punctuation); 261 if (punct && typeof punct === 'object') { 262 Object.entries(punct).forEach(([char, action]) => { 263 if (!char) return; 264 const rx = new RegExp(escapeRegExp(char), 'g'); 265 if (action === 'remove') { 266 slug = slug.replace(rx, ''); 267 } else if (action === 'replace') { 268 // Replace with space; separators handled later. 269 slug = slug.replace(rx, ' '); 270 } 271 }); 272 } 273 274 // C) Lowercase if configured. 275 if (lowercase) { 276 slug = slug.toLowerCase(); 277 } 278 279 // Safety net: turn any other stray symbols into spaces (keep digits/letters/_/- and spaces). 280 slug = slug.replace(/[^0-9A-Za-z\s_-]+/g, ' '); 281 282 // D) Normalize whitespace/underscores -> separator, collapse and trim. 283 const sepEsc = escapeRegExp(separator); 284 slug = slug 285 .trim() 286 .replace(/[_\s]+/g, separator) 287 .replace(new RegExp(`${sepEsc}+`, 'g'), separator) 288 .replace(new RegExp(`^${sepEsc}+|${sepEsc}+$`, 'g'), ''); 289 290 // E) Enforce max length and optionally cut back to the last separator. 291 if (slug.length > maxLen) { 292 slug = slug.substring(0, maxLen); 293 const lastSep = slug.lastIndexOf(separator); 294 if (lastSep > Math.floor(maxLen / 2)) { 295 slug = slug.substring(0, lastSep); 296 } 297 } 298 299 return slug; 300 } 301 302 // Public API 303 return { 304 configure, 305 getConfig, 306 generateSlug, 307 }; 308}));
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.