[{"data":1,"prerenderedAt":41},["ShallowReactive",2],{"4494947":3},{"type_of":4,"id":5,"title":6,"description":7,"readable_publish_date":8,"slug":9,"path":10,"url":11,"comments_count":12,"public_reactions_count":13,"collection_id":14,"published_timestamp":15,"language":16,"subforem_id":17,"ai_disclosure_level":18,"ai_disclosure_label":19,"positive_reactions_count":13,"cover_image":14,"social_image":20,"canonical_url":11,"created_at":15,"edited_at":14,"crossposted_at":14,"published_at":15,"last_comment_at":15,"reading_time_minutes":21,"tag_list":22,"tags":23,"body_html":27,"body_markdown":28,"user":29,"organization":36},"article",4494947,"Counting text is harder than it looks (especially once Chinese and emoji show up)","I used to think a word counter was the most boring tool imaginable. Split on spaces, maybe trim a...","Aug 26","counting-text-is-harder-than-it-looks-especially-once-chinese-and-emoji-show-up-3amh","/begoodtool/counting-text-is-harder-than-it-looks-especially-once-chinese-and-emoji-show-up-3amh","https://dev.to/begoodtool/counting-text-is-harder-than-it-looks-especially-once-chinese-and-emoji-show-up-3amh",0,3,null,"2026-08-26T13:00:02Z","en",1,"not_disclosed","Not Disclosed","https://media2.dev.to/dynamic/image/width=1200,height=627,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff7b25veky74uw9s3q51d.png",4,"javascript, vue, algorithms",[24,25,26],"javascript","vue","algorithms","\u003Cp>I used to think a word counter was the most boring tool imaginable. Split on spaces, maybe trim a newline, done.\u003C/p>\n\n\u003Cp>That illusion lasted right up until I had to handle Chinese text, mixed Chinese+English text, and emoji in the same input box. Once you do that, even the question \"what counts as one character?\" stops being obvious.\u003C/p>\n\n\u003Ch2>\n  \u003Ca name=\"word-count-breaks-the-moment-spaces-stop-meaning-words\" href=\"#word-count-breaks-the-moment-spaces-stop-meaning-words\">\n  \u003C/a>\n  \"Word count\" breaks the moment spaces stop meaning words\n\u003C/h2>\n\n\u003Cp>The core decision in this tool is that it doesn't treat every language like English. Instead of assuming whitespace-separated words are the main unit, it checks how much of the input is CJK and switches the highlighted metric when that ratio gets high enough:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight javascript\">\u003Ccode>\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">CJK_RANGE\u003C/span> \u003Cspan class=\"o\">=\u003C/span>\n  \u003Cspan class=\"sr\">/\u003C/span>\u003Cspan class=\"se\">[\u003C/span>\u003Cspan class=\"sr\">⺀-⻿぀-ヿ㐀-䶿一-鿿豈-﫿가-힣\u003C/span>\u003Cspan class=\"se\">]\u003C/span>\u003Cspan class=\"sr\">/\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n\n\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">cjkCharCount\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nf\">computed\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\n  \u003Cspan class=\"p\">()\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"nx\">graphemes\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">value\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">filter\u003C/span>\u003Cspan class=\"p\">((\u003C/span>\u003Cspan class=\"nx\">ch\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"nx\">CJK_RANGE\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">test\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">ch\u003C/span>\u003Cspan class=\"p\">)).\u003C/span>\u003Cspan class=\"nx\">length\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n\u003Cspan class=\"p\">);\u003C/span>\n\n\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">isCjkDominant\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nf\">computed\u003C/span>\u003Cspan class=\"p\">(()\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n  \u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">nonSpace\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nx\">charCountNoSpaces\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">value\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n  \u003Cspan class=\"k\">if \u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">nonSpace\u003C/span> \u003Cspan class=\"o\">===\u003C/span> \u003Cspan class=\"mi\">0\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"k\">return\u003C/span> \u003Cspan class=\"kc\">false\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n  \u003Cspan class=\"k\">return\u003C/span> \u003Cspan class=\"nx\">cjkCharCount\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">value\u003C/span> \u003Cspan class=\"o\">/\u003C/span> \u003Cspan class=\"nx\">nonSpace\u003C/span> \u003Cspan class=\"o\">&gt;=\u003C/span> \u003Cspan class=\"mf\">0.35\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n\u003Cspan class=\"p\">});\u003C/span>\n\n\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">wordCount\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nf\">computed\u003C/span>\u003Cspan class=\"p\">(()\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n  \u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">trimmed\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">inputText\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">value\u003C/span> \u003Cspan class=\"o\">||\u003C/span> \u003Cspan class=\"dl\">\"\"\u003C/span>\u003Cspan class=\"p\">).\u003C/span>\u003Cspan class=\"nf\">trim\u003C/span>\u003Cspan class=\"p\">();\u003C/span>\n  \u003Cspan class=\"k\">if \u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"o\">!\u003C/span>\u003Cspan class=\"nx\">trimmed\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"k\">return\u003C/span> \u003Cspan class=\"mi\">0\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n  \u003Cspan class=\"k\">return\u003C/span> \u003Cspan class=\"nx\">trimmed\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">split\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"sr\">/\u003C/span>\u003Cspan class=\"se\">\\s\u003C/span>\u003Cspan class=\"sr\">+/\u003C/span>\u003Cspan class=\"p\">).\u003C/span>\u003Cspan class=\"nf\">filter\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nb\">Boolean\u003C/span>\u003Cspan class=\"p\">).\u003C/span>\u003Cspan class=\"nx\">length\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n\u003Cspan class=\"p\">});\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>That \u003Ccode>0.35\u003C/code> threshold is the interesting part. The tool is not trying to do true linguistic segmentation. It's making a practical UI decision: if enough of the non-space characters are CJK, then \"characters\" is probably the more honest primary unit than \"words.\"\u003C/p>\n\n\u003Cp>That's also why mixed text behaves reasonably. A sentence like \u003Ccode>今天 shipping fix to prod\u003C/code> still shows both numbers, but the tool decides which one deserves the visual emphasis instead of pretending one rule fits every script.\u003C/p>\n\n\u003Ch2>\n  \u003Ca name=\"it-avoids-the-classic-javascript-raw-length-endraw-trap-mostly\" href=\"#it-avoids-the-classic-javascript-raw-length-endraw-trap-mostly\">\n  \u003C/a>\n  It avoids the classic JavaScript \u003Ccode>.length\u003C/code> trap — mostly\n\u003C/h2>\n\n\u003Cp>If this were using plain string \u003Ccode>.length\u003C/code>, emoji and other non-BMP characters would get overcounted because JavaScript strings are UTF-16 under the hood. The component explicitly avoids that:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight javascript\">\u003Ccode>\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">graphemes\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nf\">computed\u003C/span>\u003Cspan class=\"p\">(()\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"nb\">Array\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"k\">from\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">inputText\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">value\u003C/span> \u003Cspan class=\"o\">||\u003C/span> \u003Cspan class=\"dl\">\"\"\u003C/span>\u003Cspan class=\"p\">));\u003C/span>\n\n\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">charCountWithSpaces\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nf\">computed\u003C/span>\u003Cspan class=\"p\">(()\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"nx\">graphemes\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">value\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">length\u003C/span>\u003Cspan class=\"p\">);\u003C/span>\n\n\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">charCountNoSpaces\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nf\">computed\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\n  \u003Cspan class=\"p\">()\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"nx\">graphemes\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">value\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">filter\u003C/span>\u003Cspan class=\"p\">((\u003C/span>\u003Cspan class=\"nx\">ch\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"o\">!\u003C/span>\u003Cspan class=\"sr\">/\u003C/span>\u003Cspan class=\"se\">\\s\u003C/span>\u003Cspan class=\"sr\">/\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">test\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">ch\u003C/span>\u003Cspan class=\"p\">)).\u003C/span>\u003Cspan class=\"nx\">length\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n\u003Cspan class=\"p\">);\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>\u003Ccode>Array.from()\u003C/code> iterates by Unicode code point, which is already much better than counting UTF-16 code units. A single 😀 won't accidentally become 2 characters.\u003C/p>\n\n\u003Cp>I also like that the no-space count is derived from the same array instead of from a second regex-heavy pass over the raw string. It keeps the counting model consistent: first normalize into countable units, then filter those units.\u003C/p>\n\n\u003Ch2>\n  \u003Ca name=\"reading-time-sentences-and-paragraphs-are-all-heuristics-too\" href=\"#reading-time-sentences-and-paragraphs-are-all-heuristics-too\">\n  \u003C/a>\n  Reading time, sentences, and paragraphs are all heuristics too\n\u003C/h2>\n\n\u003Cp>The tool doesn't just count text units. It also changes the reading-time formula depending on the detected script, and it uses lightweight regex rules for sentences and paragraphs:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight javascript\">\u003Ccode>\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">CJK_CHARS_PER_MINUTE\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"mi\">300\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">WORDS_PER_MINUTE\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"mi\">200\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n\n\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">readingTimeSeconds\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nf\">computed\u003C/span>\u003Cspan class=\"p\">(()\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n  \u003Cspan class=\"k\">if \u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">isCjkDominant\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">value\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n    \u003Cspan class=\"k\">return\u003C/span> \u003Cspan class=\"nb\">Math\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">ceil\u003C/span>\u003Cspan class=\"p\">((\u003C/span>\u003Cspan class=\"nx\">charCountNoSpaces\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">value\u003C/span> \u003Cspan class=\"o\">/\u003C/span> \u003Cspan class=\"nx\">CJK_CHARS_PER_MINUTE\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"o\">*\u003C/span> \u003Cspan class=\"mi\">60\u003C/span>\u003Cspan class=\"p\">);\u003C/span>\n  \u003Cspan class=\"p\">}\u003C/span>\n  \u003Cspan class=\"k\">return\u003C/span> \u003Cspan class=\"nb\">Math\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">ceil\u003C/span>\u003Cspan class=\"p\">((\u003C/span>\u003Cspan class=\"nx\">wordCount\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">value\u003C/span> \u003Cspan class=\"o\">/\u003C/span> \u003Cspan class=\"nx\">WORDS_PER_MINUTE\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"o\">*\u003C/span> \u003Cspan class=\"mi\">60\u003C/span>\u003Cspan class=\"p\">);\u003C/span>\n\u003Cspan class=\"p\">});\u003C/span>\n\n\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">sentenceCount\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nf\">computed\u003C/span>\u003Cspan class=\"p\">(()\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n  \u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">trimmed\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">inputText\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">value\u003C/span> \u003Cspan class=\"o\">||\u003C/span> \u003Cspan class=\"dl\">\"\"\u003C/span>\u003Cspan class=\"p\">).\u003C/span>\u003Cspan class=\"nf\">trim\u003C/span>\u003Cspan class=\"p\">();\u003C/span>\n  \u003Cspan class=\"k\">if \u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"o\">!\u003C/span>\u003Cspan class=\"nx\">trimmed\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"k\">return\u003C/span> \u003Cspan class=\"mi\">0\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n  \u003Cspan class=\"k\">return\u003C/span> \u003Cspan class=\"nx\">trimmed\u003C/span>\n    \u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">split\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"sr\">/\u003C/span>\u003Cspan class=\"se\">[\u003C/span>\u003Cspan class=\"sr\">.!?。！？…\u003C/span>\u003Cspan class=\"se\">]\u003C/span>\u003Cspan class=\"sr\">+/\u003C/span>\u003Cspan class=\"p\">)\u003C/span>\n    \u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">map\u003C/span>\u003Cspan class=\"p\">((\u003C/span>\u003Cspan class=\"nx\">s\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"nx\">s\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">trim\u003C/span>\u003Cspan class=\"p\">())\u003C/span>\n    \u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">filter\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nb\">Boolean\u003C/span>\u003Cspan class=\"p\">).\u003C/span>\u003Cspan class=\"nx\">length\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n\u003Cspan class=\"p\">});\u003C/span>\n\n\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">paragraphCount\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nf\">computed\u003C/span>\u003Cspan class=\"p\">(()\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n  \u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">text\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nx\">inputText\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">value\u003C/span> \u003Cspan class=\"o\">||\u003C/span> \u003Cspan class=\"dl\">\"\"\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n  \u003Cspan class=\"k\">if \u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"o\">!\u003C/span>\u003Cspan class=\"nx\">text\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">trim\u003C/span>\u003Cspan class=\"p\">())\u003C/span> \u003Cspan class=\"k\">return\u003C/span> \u003Cspan class=\"mi\">0\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n  \u003Cspan class=\"k\">return\u003C/span> \u003Cspan class=\"nx\">text\u003C/span>\n    \u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">split\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"sr\">/\u003C/span>\u003Cspan class=\"se\">\\n\\s\u003C/span>\u003Cspan class=\"sr\">*\u003C/span>\u003Cspan class=\"se\">\\n\u003C/span>\u003Cspan class=\"sr\">+/\u003C/span>\u003Cspan class=\"p\">)\u003C/span>\n    \u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">map\u003C/span>\u003Cspan class=\"p\">((\u003C/span>\u003Cspan class=\"nx\">s\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"nx\">s\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">trim\u003C/span>\u003Cspan class=\"p\">())\u003C/span>\n    \u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">filter\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nb\">Boolean\u003C/span>\u003Cspan class=\"p\">).\u003C/span>\u003Cspan class=\"nx\">length\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n\u003Cspan class=\"p\">});\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>This is the part I think most counters quietly hand-wave away. \"Reading time\" is not a universal constant, and even sentence counting depends on punctuation conventions. Here the code at least makes the tradeoff explicit: CJK gets characters-per-minute, space-delimited languages get words-per-minute, and paragraphs mean blank-line-separated blocks.\u003C/p>\n\n\u003Ch2>\n  \u003Ca name=\"where-it-still-gets-fuzzy\" href=\"#where-it-still-gets-fuzzy\">\n  \u003C/a>\n  Where it still gets fuzzy\n\u003C/h2>\n\n\u003Cp>There are a couple of honest limitations in the current implementation.\u003C/p>\n\n\u003Cp>First, \u003Ccode>graphemes\u003C/code> is a slightly misleading variable name. \u003Ccode>Array.from()\u003C/code> counts code points, not full grapheme clusters. That means some visually single characters — like family emoji, flags, or emoji plus skin-tone modifiers — can still count as more than one.\u003C/p>\n\n\u003Cp>Second, sentence splitting is intentionally simple. A regex like \u003Ccode>/[.!?。！？…]+/\u003C/code> will also split on things that are not really sentence boundaries, like \u003Ccode>3.14\u003C/code> or \u003Ccode>e.g.\u003C/code>. And the CJK detection threshold is still a heuristic, so a mixed-language paragraph near 35% can flip the \"primary\" unit in a way that feels a little arbitrary.\u003C/p>\n\n\u003Cp>I wrapped that logic into a small free tool: \u003Ca href=\"https://begoodtool.com/word-character-counter/en\" target=\"_blank\" rel=\"noopener noreferrer\">Word &amp; Character Counter\u003C/a>.\u003C/p>\n\n\n\u003Chr>\n\n\u003Ch2>\n  \u003Ca name=\"available-in-other-languages\" href=\"#available-in-other-languages\">\n  \u003C/a>\n  Available in other languages\n\u003C/h2>\n\n\u003Cul>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter\" target=\"_blank\" rel=\"noopener noreferrer\">字數字元計數器\u003C/a> — 繁體中文\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/cn\" target=\"_blank\" rel=\"noopener noreferrer\">字数字符计数器\u003C/a> — 简体中文\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/en\" target=\"_blank\" rel=\"noopener noreferrer\">Word &amp; Character Counter\u003C/a> — English\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/jp\" target=\"_blank\" rel=\"noopener noreferrer\">文字数・文字カウンター\u003C/a> — 日本語\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/kr\" target=\"_blank\" rel=\"noopener noreferrer\">글자수·단어수 카운터\u003C/a> — 한국어\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/fr\" target=\"_blank\" rel=\"noopener noreferrer\">Compteur de mots et de caractères\u003C/a> — Français\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/ru\" target=\"_blank\" rel=\"noopener noreferrer\">Счётчик слов и символов\u003C/a> — Русский\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/de\" target=\"_blank\" rel=\"noopener noreferrer\">Wörter- und Zeichenzähler\u003C/a> — Deutsch\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/id\" target=\"_blank\" rel=\"noopener noreferrer\">Penghitung Kata dan Karakter\u003C/a> — Bahasa Indonesia\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/es\" target=\"_blank\" rel=\"noopener noreferrer\">Contador de Palabras y Caracteres\u003C/a> — Español\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/vi\" target=\"_blank\" rel=\"noopener noreferrer\">Công Cụ Đếm Từ và Ký Tự\u003C/a> — Tiếng Việt\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/th\" target=\"_blank\" rel=\"noopener noreferrer\">เครื่องมือนับคำและตัวอักษร\u003C/a> — ไทย\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/pl\" target=\"_blank\" rel=\"noopener noreferrer\">Licznik Słów i Znaków\u003C/a> — Polski\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/tr\" target=\"_blank\" rel=\"noopener noreferrer\">Kelime ve Karakter Sayacı\u003C/a> — Türkçe\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/it\" target=\"_blank\" rel=\"noopener noreferrer\">Contatore di Parole e Caratteri\u003C/a> — Italiano\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/pt\" target=\"_blank\" rel=\"noopener noreferrer\">Contador de Palavras e Caracteres\u003C/a> — Português\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/nl\" target=\"_blank\" rel=\"noopener noreferrer\">Woorden- en Tekenteller\u003C/a> — Nederlands\u003C/li>\n\u003Cli>\n\u003Ca href=\"https://begoodtool.com/word-character-counter/uk\" target=\"_blank\" rel=\"noopener noreferrer\">Рахувач Слів і Символів\u003C/a> — Українська\u003C/li>\n\u003C/ul>\n\n","I used to think a word counter was the most boring tool imaginable. Split on spaces, maybe trim a newline, done.\r\n\r\nThat illusion lasted right up until I had to handle Chinese text, mixed Chinese+English text, and emoji in the same input box. Once you do that, even the question \"what counts as one character?\" stops being obvious.\r\n\r\n## \"Word count\" breaks the moment spaces stop meaning words\r\n\r\nThe core decision in this tool is that it doesn't treat every language like English. Instead of assuming whitespace-separated words are the main unit, it checks how much of the input is CJK and switches the highlighted metric when that ratio gets high enough:\r\n\r\n```js\r\nconst CJK_RANGE =\r\n  /[⺀-⻿぀-ヿ㐀-䶿一-鿿豈-﫿가-힣]/;\r\n\r\nconst cjkCharCount = computed(\r\n  () => graphemes.value.filter((ch) => CJK_RANGE.test(ch)).length,\r\n);\r\n\r\nconst isCjkDominant = computed(() => {\r\n  const nonSpace = charCountNoSpaces.value;\r\n  if (nonSpace === 0) return false;\r\n  return cjkCharCount.value / nonSpace >= 0.35;\r\n});\r\n\r\nconst wordCount = computed(() => {\r\n  const trimmed = (inputText.value || \"\").trim();\r\n  if (!trimmed) return 0;\r\n  return trimmed.split(/\\s+/).filter(Boolean).length;\r\n});\r\n```\r\n\r\nThat `0.35` threshold is the interesting part. The tool is not trying to do true linguistic segmentation. It's making a practical UI decision: if enough of the non-space characters are CJK, then \"characters\" is probably the more honest primary unit than \"words.\"\r\n\r\nThat's also why mixed text behaves reasonably. A sentence like `今天 shipping fix to prod` still shows both numbers, but the tool decides which one deserves the visual emphasis instead of pretending one rule fits every script.\r\n\r\n## It avoids the classic JavaScript `.length` trap — mostly\r\n\r\nIf this were using plain string `.length`, emoji and other non-BMP characters would get overcounted because JavaScript strings are UTF-16 under the hood. The component explicitly avoids that:\r\n\r\n```js\r\nconst graphemes = computed(() => Array.from(inputText.value || \"\"));\r\n\r\nconst charCountWithSpaces = computed(() => graphemes.value.length);\r\n\r\nconst charCountNoSpaces = computed(\r\n  () => graphemes.value.filter((ch) => !/\\s/.test(ch)).length,\r\n);\r\n```\r\n\r\n`Array.from()` iterates by Unicode code point, which is already much better than counting UTF-16 code units. A single 😀 won't accidentally become 2 characters.\r\n\r\nI also like that the no-space count is derived from the same array instead of from a second regex-heavy pass over the raw string. It keeps the counting model consistent: first normalize into countable units, then filter those units.\r\n\r\n## Reading time, sentences, and paragraphs are all heuristics too\r\n\r\nThe tool doesn't just count text units. It also changes the reading-time formula depending on the detected script, and it uses lightweight regex rules for sentences and paragraphs:\r\n\r\n```js\r\nconst CJK_CHARS_PER_MINUTE = 300;\r\nconst WORDS_PER_MINUTE = 200;\r\n\r\nconst readingTimeSeconds = computed(() => {\r\n  if (isCjkDominant.value) {\r\n    return Math.ceil((charCountNoSpaces.value / CJK_CHARS_PER_MINUTE) * 60);\r\n  }\r\n  return Math.ceil((wordCount.value / WORDS_PER_MINUTE) * 60);\r\n});\r\n\r\nconst sentenceCount = computed(() => {\r\n  const trimmed = (inputText.value || \"\").trim();\r\n  if (!trimmed) return 0;\r\n  return trimmed\r\n    .split(/[.!?。！？…]+/)\r\n    .map((s) => s.trim())\r\n    .filter(Boolean).length;\r\n});\r\n\r\nconst paragraphCount = computed(() => {\r\n  const text = inputText.value || \"\";\r\n  if (!text.trim()) return 0;\r\n  return text\r\n    .split(/\\n\\s*\\n+/)\r\n    .map((s) => s.trim())\r\n    .filter(Boolean).length;\r\n});\r\n```\r\n\r\nThis is the part I think most counters quietly hand-wave away. \"Reading time\" is not a universal constant, and even sentence counting depends on punctuation conventions. Here the code at least makes the tradeoff explicit: CJK gets characters-per-minute, space-delimited languages get words-per-minute, and paragraphs mean blank-line-separated blocks.\r\n\r\n## Where it still gets fuzzy\r\n\r\nThere are a couple of honest limitations in the current implementation.\r\n\r\nFirst, `graphemes` is a slightly misleading variable name. `Array.from()` counts code points, not full grapheme clusters. That means some visually single characters — like family emoji, flags, or emoji plus skin-tone modifiers — can still count as more than one.\r\n\r\nSecond, sentence splitting is intentionally simple. A regex like `/[.!?。！？…]+/` will also split on things that are not really sentence boundaries, like `3.14` or `e.g.`. And the CJK detection threshold is still a heuristic, so a mixed-language paragraph near 35% can flip the \"primary\" unit in a way that feels a little arbitrary.\r\n\r\nI wrapped that logic into a small free tool: [Word & Character Counter](https://begoodtool.com/word-character-counter/en).\r\n\r\n---\r\n\r\n## Available in other languages\r\n\r\n- [字數字元計數器](https://begoodtool.com/word-character-counter) — 繁體中文\r\n- [字数字符计数器](https://begoodtool.com/word-character-counter/cn) — 简体中文\r\n- [Word & Character Counter](https://begoodtool.com/word-character-counter/en) — English\r\n- [文字数・文字カウンター](https://begoodtool.com/word-character-counter/jp) — 日本語\r\n- [글자수·단어수 카운터](https://begoodtool.com/word-character-counter/kr) — 한국어\r\n- [Compteur de mots et de caractères](https://begoodtool.com/word-character-counter/fr) — Français\r\n- [Счётчик слов и символов](https://begoodtool.com/word-character-counter/ru) — Русский\r\n- [Wörter- und Zeichenzähler](https://begoodtool.com/word-character-counter/de) — Deutsch\r\n- [Penghitung Kata dan Karakter](https://begoodtool.com/word-character-counter/id) — Bahasa Indonesia\r\n- [Contador de Palabras y Caracteres](https://begoodtool.com/word-character-counter/es) — Español\r\n- [Công Cụ Đếm Từ và Ký Tự](https://begoodtool.com/word-character-counter/vi) — Tiếng Việt\r\n- [เครื่องมือนับคำและตัวอักษร](https://begoodtool.com/word-character-counter/th) — ไทย\r\n- [Licznik Słów i Znaków](https://begoodtool.com/word-character-counter/pl) — Polski\r\n- [Kelime ve Karakter Sayacı](https://begoodtool.com/word-character-counter/tr) — Türkçe\r\n- [Contatore di Parole e Caratteri](https://begoodtool.com/word-character-counter/it) — Italiano\r\n- [Contador de Palavras e Caracteres](https://begoodtool.com/word-character-counter/pt) — Português\r\n- [Woorden- en Tekenteller](https://begoodtool.com/word-character-counter/nl) — Nederlands\r\n- [Рахувач Слів і Символів](https://begoodtool.com/word-character-counter/uk) — Українська",{"name":30,"username":31,"twitter_username":14,"github_username":14,"user_id":32,"website_url":33,"profile_image":34,"profile_image_90":35},"Joe Lin","yuntao_lin",4066889,"https://begoodtool.com/home/en","https://media2.dev.to/dynamic/image/width=640,height=640,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4066889%2Fcbfd5bde-d893-4635-97d5-aea05722cb26.jpeg","https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4066889%2Fcbfd5bde-d893-4635-97d5-aea05722cb26.jpeg",{"name":37,"username":38,"slug":38,"profile_image":39,"profile_image_90":40},"BeGoodTool.com","begoodtool","https://media2.dev.to/dynamic/image/width=640,height=640,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F14295%2Fd519278f-433a-4dab-9489-447ef51fc6d4.jpg","https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F14295%2Fd519278f-433a-4dab-9489-447ef51fc6d4.jpg",1788519848657]