Repository: harttle/liquidjs
Pull request #695 (2024-05-13 15:34:29)

feat: slugify filter from Jekyll, #443

coveralls: ## Pull Request Test Coverage Report for [Build 9065752719](https://coveralls.io/builds/67457517)


### Details

* **10** of **10**   **(100.0%)**  changed or added relevant lines in **1** file are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.005%**) to **99.158%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/67457517/badge)](https://coveralls.io/builds/67457517) |
| :-- | --: |
| Change from base [Build 9052818827](https://coveralls.io/builds/67440252): |  0.005% |
| Covered Lines: | 2371 |
| Relevant Lines: | 2382 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/docs/source/_data/sidebar.yml b/docs/source/_data/sidebar.yml
index 6aa1d0c22..5dfb9a1f9 100644
--- a/docs/source/_data/sidebar.yml
+++ b/docs/source/_data/sidebar.yml
@@ -82,6 +82,7 @@ filters:
   shift: shift.html
   size: size.html
   slice: slice.html
+  slugify: slugify.html
   sort: sort.html
   sort_natural: sort_natural.html
   split: split.html
diff --git a/docs/source/filters/overview.md b/docs/source/filters/overview.md
index 447715e8c..b32b4c802 100644
--- a/docs/source/filters/overview.md
+++ b/docs/source/filters/overview.md
@@ -11,7 +11,7 @@ Categories | Filters
 --- | ---
 Math | plus, minus, modulo, times, floor, ceil, round, divided_by, abs, at_least, at_most
 String | append, prepend, capitalize, upcase, downcase, strip, lstrip, rstrip, strip_newlines, split, replace, replace_first, replace_last,remove, remove_first, remove_last, truncate, truncatewords, normalize_whitespace, number_of_words, array_to_sentence_string
-HTML/URI | escape, escape_once, url_encode, url_decode, strip_html, newline_to_br, xml_escape, cgi_escape, uri_escape
+HTML/URI | escape, escape_once, url_encode, url_decode, strip_html, newline_to_br, xml_escape, cgi_escape, uri_escape, slugify
 Array | slice, map, sort, sort_natural, uniq, where, where_exp, group_by, group_by_exp, find, find_exp, first, last, join, reverse, concat, compact, size, push, pop, shift, unshift
 Date | date, date_to_xmlschema, date_to_rfc822, date_to_string, date_to_long_string
 Misc | default, json, jsonify, inspect, raw, to_integer
diff --git a/docs/source/filters/slugify.md b/docs/source/filters/slugify.md
new file mode 100644
index 000000000..afea1d1c4
--- /dev/null
+++ b/docs/source/filters/slugify.md
@@ -0,0 +1,61 @@
+---
+title: slugify
+---
+
+{% since %}v10.13.0{% endsince %}
+
+Convert a string into a lowercase URL "slug". The slugify filter accepts 2 options:
+
+1. `mode: string`. The default is `"default"`. They are as follows (with what they filter):
+  - `"none"`: no characters
+  - `"raw"`: spaces
+  - `"default"`: spaces and non-alphanumeric characters
+  - `"pretty"`: spaces and non-alphanumeric characters except for `._~!$&'()+,;=@`
+  - `"ascii"`: spaces, non-alphanumeric, and non-ASCII characters
+  - `"latin"`: like default, except Latin characters are first transliterated (e.g. àèïòü to aeiou).
+2. `case: boolean`. The default is `false`. The original case of slug will be retained if set to `true`.
+
+Input
+```liquid
+{{ "The _config.yml file" | slugify }}
+```
+Output
+```
+the-config-yml-file
+```
+
+Input
+```liquid
+{{ "The _config.yml file" | slugify: "pretty" }}
+```
+Output
+```
+the-_config.yml-file
+```
+
+Input
+```liquid
+{{ "The _cönfig.yml file" | slugify: "ascii" }}
+```
+Output
+```
+the-c-nfig-yml-file
+```
+
+Input
+```liquid
+{{ "The cönfig.yml file" | slugify: "latin" }}
+```
+Output
+```
+the-config-yml-file
+```
+
+Input
+```liquid
+{{ "The cönfig.yml file" | slugify: "latin", true }}
+```
+Output
+```
+The-config-yml-file
+```
diff --git a/docs/source/zh-cn/filters/overview.md b/docs/source/zh-cn/filters/overview.md
index 97d6b2134..3e19c0852 100644
--- a/docs/source/zh-cn/filters/overview.md
+++ b/docs/source/zh-cn/filters/overview.md
@@ -11,7 +11,7 @@ LiquidJS 共支持 40+ 个过滤器，可以分为如下几类：
 --- | ---
 数学 | plus, minus, modulo, times, floor, ceil, round, divided_by, abs, at_least, at_most
 字符串 | append, prepend, capitalize, upcase, downcase, strip, lstrip, rstrip, strip_newlines, split, replace, replace_first, replace_last, remove, remove_first, remove_last, truncate, truncatewords, normalize_whitespace, number_of_words, array_to_sentence_string
-HTML/URI | escape, escape_once, url_encode, url_decode, strip_html, newline_to_br, xml_escape, cgi_escape, uri_escape
+HTML/URI | escape, escape_once, url_encode, url_decode, strip_html, newline_to_br, xml_escape, cgi_escape, uri_escape, slugify
 数组 | slice, map, sort, sort_natural, uniq, where, where_exp, group_by, group_by_exp, find, find_exp, first, last, join, reverse, concat, compact, size, push, pop, shift, unshift
 日期 | date, date_to_xmlschema, date_to_rfc822, date_to_string, date_to_long_string
 其他 | default, json, jsonify, inspect, raw, to_integer
diff --git a/docs/source/zh-cn/filters/slugify.md b/docs/source/zh-cn/filters/slugify.md
new file mode 100644
index 000000000..c6fd88260
--- /dev/null
+++ b/docs/source/zh-cn/filters/slugify.md
@@ -0,0 +1,59 @@
+---
+title: slugify
+---
+
+将字符串转换为小写的 URL “slug”。`slugify` 过滤器接受两个选项：
+
+1. `mode: string`。默认为`"default"`，它可选的值如下：
+   - `"none"`：没有字符
+   - `"raw"`：空格
+   - `"default"`：空格和非字母数字字符
+   - `"pretty"`：空格和非字母数字字符，但排除 `._~!$&'()+,;=@`
+   - `"ascii"`：空格、非字母数字和非 ASCII 字符
+   - `"latin"`：与默认相同，但拉丁字符首先进行音译（例如，àèïòü 转换为 aeiou）。
+2. `case: boolean`。默认为 `false`。如果为 `true`，则保留 `slug` 原本的大小写。
+
+输入
+```liquid
+{{ "The _config.yml file" | slugify }}
+```
+输出
+```
+the-config-yml-file
+```
+
+输入
+```liquid
+{{ "The _config.yml file" | slugify: "pretty" }}
+```
+输出
+```
+the-_config.yml-file
+```
+
+输入
+```liquid
+{{ "The _cönfig.yml file" | slugify: "ascii" }}
+```
+输出
+```
+the-c-nfig-yml-file
+```
+
+输入
+```liquid
+{{ "The cönfig.yml file" | slugify: "latin" }}
+```
+输出
+```
+the-config-yml-file
+```
+
+输入
+```liquid
+{{ "The cönfig.yml file" | slugify: "latin", true }}
+```
+输出
+```
+The-config-yml-file
+```
diff --git a/docs/themes/navy/source/css/_variables.styl b/docs/themes/navy/source/css/_variables.styl
index 2d6ea544a..65fa9728d 100644
--- a/docs/themes/navy/source/css/_variables.styl
+++ b/docs/themes/navy/source/css/_variables.styl
@@ -36,8 +36,8 @@ vendor-prefixes = webkit moz ms official
 font-sans = "Helvetica Neue", Helvetica, Arial, sans-serif
 font-serif = Garamond, Georgia, "Times New Roman", serif
 font-mono = "Source Code Pro", Monaco, Menlo, Consolas, monospace
-font-size = 15px
-line-height = 1.6em
+font-size = 16px
+line-height = 1.8em
 
 // Layout
 max-width = 1800px
diff --git a/src/filters/url.ts b/src/filters/url.ts
index d2e3b4989..77a9cab1c 100644
--- a/src/filters/url.ts
+++ b/src/filters/url.ts
@@ -8,3 +8,44 @@ export const cgi_escape = (x: string) => encodeURIComponent(stringify(x))
 export const uri_escape = (x: string) => encodeURI(stringify(x))
   .replace(/%5B/g, '[')
   .replace(/%5D/g, ']')
+
+const rSlugifyDefault = /[^\p{M}\p{L}\p{Nd}]+/ug
+const rSlugifyReplacers = {
+  'raw': /\s+/g,
+  'default': rSlugifyDefault,
+  'pretty': /[^\p{M}\p{L}\p{Nd}._~!$&'()+,;=@]+/ug,
+  'ascii': /[^A-Za-z0-9]+/g,
+  'latin': rSlugifyDefault,
+  'none': null
+}
+
+export function slugify (str: string, mode: keyof typeof rSlugifyReplacers = 'default', cased = false): string {
+  str = stringify(str)
+
+  const replacer = rSlugifyReplacers[mode]
+  if (replacer) {
+    if (mode === 'latin') str = removeAccents(str)
+    str = str.replace(replacer, '-').replace(/^-|-$/g, '')
+  }
+
+  return cased ? str : str.toLowerCase()
+}
+
+function removeAccents (str: string): string {
+  return str.replace(/[àáâãäå]/g, 'a')
+    .replace(/[æ]/g, 'ae')
+    .replace(/[ç]/g, 'c')
+    .replace(/[èéêë]/g, 'e')
+    .replace(/[ìíîï]/g, 'i')
+    .replace(/[ð]/g, 'd')
+    .replace(/[ñ]/g, 'n')
+    .replace(/[òóôõöø]/g, 'o')
+    .replace(/[ùúûü]/g, 'u')
+    .replace(/[ýÿ]/g, 'y')
+    .replace(/[ß]/g, 'ss')
+    .replace(/[œ]/g, 'oe')
+    .replace(/[þ]/g, 'th')
+    .replace(/[ẞ]/g, 'SS')
+    .replace(/[Œ]/g, 'OE')
+    .replace(/[Þ]/g, 'TH')
+}


Test patch:
diff --git a/test/integration/filters/url.spec.ts b/test/integration/filters/url.spec.ts
index 47b2e5e4d..3dbd67e4f 100644
--- a/test/integration/filters/url.spec.ts
+++ b/test/integration/filters/url.spec.ts
@@ -42,4 +42,53 @@ describe('filters/url', () => {
       expect(html).toEqual(reserved)
     })
   })
+
+  describe('slugify', () => {
+    describe('slugify', () => {
+      it('should slugify with default mode', () => {
+        const html = liquid.parseAndRenderSync('{{ "The _config.yml file" | slugify }}')
+        expect(html).toEqual('the-config-yml-file')
+      })
+
+      it('should slugify with pretty mode', () => {
+        const html = liquid.parseAndRenderSync('{{ "The _config.yml file" | slugify: "pretty" }}')
+        expect(html).toEqual('the-_config.yml-file')
+      })
+
+      it('should slugify with ascii mode', () => {
+        const html = liquid.parseAndRenderSync('{{ "The _cönfig.yml file" | slugify: "ascii" }}')
+        expect(html).toEqual('the-c-nfig-yml-file')
+      })
+
+      it('should slugify with latin mode', () => {
+        const html = liquid.parseAndRenderSync('{{ "The cönfig.yml file" | slugify: "latin" }}')
+        expect(html).toEqual('the-config-yml-file')
+      })
+
+      it('should slugify with none mode', () => {
+        const html = liquid.parseAndRenderSync('{{ "The _config.yml file" | slugify: "none" }}')
+        expect(html).toEqual('the _config.yml file')
+      })
+
+      it('should slugify with invalid mode', () => {
+        const html = liquid.parseAndRenderSync('{{ "The _config.yml file" | slugify: "invalid_mode" }}')
+        expect(html).toEqual('the _config.yml file')
+      })
+
+      it('should slugify with empty string', () => {
+        const html = liquid.parseAndRenderSync('{{ "" | slugify }}')
+        expect(html).toEqual('')
+      })
+
+      it('should slugify with cased=false', () => {
+        const html = liquid.parseAndRenderSync('{{ "Test String" | slugify: "pretty", false }}')
+        expect(html).toEqual('test-string')
+      })
+
+      it('should slugify with cased=true', () => {
+        const html = liquid.parseAndRenderSync('{{ "Test String" | slugify: "pretty", true }}')
+        expect(html).toEqual('Test-String')
+      })
+    })
+  })
 })