emdash-cms

Activity

Stars and mentions · Last 30 days, Oct 5 – Nov 3

GitHub stars512
Mentions20
012243648Oct 5Oct 19Nov 3
UTC days · repositories emdash-cms owns2 repositories

Top repositories

emdash-cms’s repositories ranked by new stars, as on trending.

Loading

Contributes to

Repositories outside emdash-cms’s account where emdash-cms opened or reviewed pull requests in the past 365 days.

Who noticed emdash-cms

Everyone who starred or forked emdash-cms’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

20posts
Nov 42 posts
  1. Migrate WordPress to EmDash: The Cloudflare Guide --- title: "Migrate WordPress to EmDash: The Cloudflare Guide" published: true tags: emdash, cloudflare, wordpressmigration, astro canonical_url: veduis.com cover_image: veduis.com description: "A practical Cloudflare EmDash migration guide: WXR and exporter imports, Astro theme rebuild, SEO redirects, cutover, costs, and when to stay on WordPress." --- Migrating WordPress to EmDash is two projects wearing one name. The first is a content migration: posts, pages, media, authors, and taxonomies move through an importer. The second is a theme and plugin rebuild: your PHP templates, page builder layouts, shortcodes, and plugin behavior do not move at all. Cloudflare's importer is good enough that the content side is usually a day or two. The rebuild is where the schedule lives. This guide covers the full path, including the parts that decide whether the migration is worth doing. !Migration path from a WordPress ring through an orange Cloudflare cloud to Worker hexagons and a database cylinder What EmDash actually is EmDash is an open source content management system from Cloudflare, MIT licensed, written in TypeScript and built on Astro. It is at 1.0 after a 0.1 preview in April 2026. Cloudflare says the first version was built in about two months with AI coding agents on top of Astro. The launch post is candid about that history, and the community reaction has been equally candid about the risks. The architecture matters more than the origin story: - The public site is an Astro application. - The admin is a React application served from the same project. - Content lives in D1 (SQLite) and media lives in R2 when you deploy to Cloudflare. - You can also run it on Node.js with SQLite if you do not want Cloudflare hosting. - Plugins run in sandboxed Worker isolates with declared capabilities, not as unrestricted PHP. - Authentication is passkey-first, with GitHub, Google, and Microsoft providers. Password login is not the default. What EmDash is not: a WordPress fork, a PHP runtime, a WordPress theme engine, or a visual page builder. The official docs state that it does not run WordPress themes or plugins. That single sentence should shape the entire decision. The honest cost math Cloudflare's pricing is the reason people are interested. The numbers below are the published rates as of November 2026. | Service | Included | Overage | | --- | --- | --- | | Workers Paid | $5 per month, 10 million requests, 30 million CPU milliseconds | $0.30 per million requests, $0.02 per million CPU milliseconds | | D1 | 25 billion rows read, 50 million rows written, 5 GB storage | $0.001 per million reads, $1 per million writes, $0.75 per GB-month | | R2 | 10 GB storage, 1 million Class A operations, 10 million Class B operations | $0.015 per GB-month, $4.50 per million Class A, $0.36 per million Class B | Egress is free on R2. Static assets served by Workers are free and unlimited. For a content site under 100,000 page views a month, the Cloudflare bill is often $5 to $15, plus whatever you pay for email delivery. A typical WordPress site is not free either. Managed WordPress hosting runs $15 to $60 a month. Premium plugins add $200 to $800 a year. A CDN, image optimizer, forms add-on, and backup service add more. The gap is real, but it is usually $20 to $50 a month, not thousands. That gap does not pay back a 40-hour rebuild at a typical consulting rate. The case for migrating is stronger when one of these is true: - You run many sites and the monthly savings compound. - Your WordPress stack is slow, plugin-heavy, or constantly patched. - You already build in Astro or TypeScript. - You want the security model, not just the bill. - You are launching a new site and can choose the stack before content exists. If you have a single small blog and no technical time, migrating to save $30 a month is a hobby project, not a business decision. The static site case is stronger when performance and maintenance are part of the return. !Two stacked bars comparing a taller grey WordPress cost stack with a shorter orange Cloudflare stack Decide whether to migrate at all | Site type | Fit | Why | | --- | --- | --- | | Blog, news, publication | Strong | Content model, SEO, RSS, media, revisions are covered | | Agency brochure site | Strong | Few plugins, Astro theme is quick to rebuild | | Documentation site | Strong | Static pages, search, versioned content | | WooCommerce store | Weak today | DashCommerce exists but is young; checkout risk is high | | Page builder site | Weak | Elementor, Divi, and WPBakery layouts do not import | | Membership or LMS | Weak to moderate | Auth exists, but course and subscription logic needs custom work | | Multilingual site | Moderate | i18n exists, but plugin-specific translation workflows need review | | Heavy custom PHP | Weak | Every custom plugin is a rewrite | | Mission-critical uptime | Moderate | You need staging, rollback, and a maintenance plan | A useful rule: count the plugins that change how the site behaves, not how it looks. If more than five of them are business-critical, the rebuild is a real project. If the site is mostly content and a contact form, it is a week of focused work. Audit before you touch DNS The import only moves what WordPress exposes. The audit defines what must survive. 1. Back up the database and wp-content/uploads. Test the restore. 2. Record the permalink structure, canonical origin, and every active redirect. 3. Export a crawl of every public URL with status, canonical, title, meta description, and structured data. 4. List post types, taxonomies, custom fields, and ACF groups. 5. List plugins by function: forms, SEO, cache, commerce, membership, analytics, email, backups, security. 6. List integrations: CRM, email marketing, payment, help desk, analytics events, cron jobs. 7. Identify content to keep, merge, redirect, or drop. Migrations are a good time to delete thin pages. 8. Measure current Core Web Vitals and conversion events so you can compare after cutover. Keep the crawl. It becomes the redirect map and the QA checklist. The import: WXR or the EmDash Exporter EmDash supports two import paths through the admin at /_emdash/admin/import/wordpress. The migration docs describe both. | Method | Use it when | What it includes | | --- | --- | --- | | WXR upload | You can use WordPress Tools > Export | Posts, pages, custom post types, terms, reusable blocks, authors, attachment URLs | | EmDash Exporter | You control WordPress and can install a plugin | Authenticated content, settings, SEO fields, taxonomies, media metadata, and with 1.2.0 or later, comments, menus, logo, and favicon | The WXR file points at media on the old site. It does not contain the image bytes. EmDash downloads them during the media step, so WordPress must stay reachable until that finishes. The importer compares a SHA-1 hash of the bytes, not filenames, so reruns reuse identical files. The exporter path needs an application password and, on Cloudflare, a WAF exception. Bot protection can answer the exporter with a challenge page. The docs publish the exact skip rule for /wp-json/emdash/v1/ and /wp-content/uploads/. On the Free plan, Bot Fight Mode cannot be skipped by a rule, so it has to be turned off during the import and turned back on afterwards. What converts, and what does not - publish becomes published. - draft, pending, private, future, trash, and unknown statuses become drafts. Scheduled dates and visibility rules are not reconstructed. - Gutenberg and classic HTML become Portable Text. - Server-rendered blocks, such as Latest Posts or plugin blocks, have no saved markup. They import as HTML comments with no content. You rebuild them or remove them. - WXR does not copy every custom meta key. The analysis suggests fields, but the importer writes the standard fields. - The exporter can copy ACF and Yoast or Rank Math values when the target collection has matching fields. ACF relationship and image fields keep WordPress IDs, not EmDash IDs, so they need review. - Custom taxonomies without a matching EmDash definition are reported and skipped. Verify before cutover Run these checks while WordPress is still live: - Entry counts by post type and status. - A sample of Gutenberg, classic, shortcode, and page builder content. - Featured and inline images resolve from EmDash URLs. - Authors, bylines, categories, tags, and custom taxonomies. - Menus, comments, site identity, and SEO fields if you used the exporter. - Drafts are not publicly accessible. - Every old URL either resolves or has a planned redirect. If an import is interrupted, do not delete WordPress. Rerun the media step, inspect duplicates by collection, slug, and locale, and compare counts. The admin retry matches by slug and locale, not WordPress ID. Rebuild the theme in Astro WordPress template hierarchy becomes explicit Astro routes. The WordPress developer guide maps the concepts: | WordPress | EmDash and Astro | | --- | --- | | single.php | src/pages/posts/[slug].astro | | archive.php | src/pages/posts/index.astro | | page.php | src/pages/pages/[slug].astro | | header.php | src/layouts/Base.astro | | the_content() | <PortableText /> | | wp_nav_menu() | getMenu() | A post route uses getEmDashEntry() and renders Portable Text. A layout renders <EmDashHead> for metadata and JSON-LD. Menus and widget areas are database records, not PHP files. This is also where most of the budget goes. A custom WordPress theme with 20 templates, a page builder, and a dozen shortcodes is not a weekend port. A clean blog theme is a few days. If you are still weighing headless WordPress against a full CMS rebuild, our headless WordPress guide covers the architecture tradeoffs. Replace the plugins There is no one-to-one plugin marketplace. Some functions are built in, some are plugins, and some need a third party. | WordPress function | EmDash path | | --- | --- | | SEO titles, canonicals, schema | Built in, plus template rendering | | XML sitemap and robots.txt | Built in | | Redirects and 404 log | Built in | | Caching | Workers Cache and KV object cache | | Forms | EmDash form plugin or an external form service | | Search | Pagefind, or the AI Search plugin | | Comments | Built in or a plugin, depending on the template | | Email delivery | Provider plugin (Resend, Postmark, or SMTP) | | Analytics | Cloudflare Web Analytics or an external tag | | Commerce | DashCommerce, still early | | Backups | D1 Time Travel plus R2 object sync | | Memberships | Passkeys and providers, plus custom logic | Check the plugin registry before promising parity. If a plugin is central to the business, confirm the replacement exists before you migrate, not after. Preserve SEO through the move SEO is the part that makes or breaks the project. The goal is simple: every URL that had value before the migration should resolve to the same content, or redirect to the closest equivalent, with one hop. !A column of old page rectangles on the left, arrows through an orange cloud, and matching new page rectangles on the right Keep the URL structure Do not change permalinks and platform in the same release. Keep the same paths, including trailing slashes, category prefixes, and date archives if they have traffic. EmDash route patterns are explicit, so you can reproduce almost any structure. Map every changed URL Build a spreadsheet with old URL, new URL, status, and reason. Use 301 for permanent moves and 410 for pages you intentionally delete. Do not redirect everything to the homepage. EmDash creates a 301 automatically when a published entry's slug changes. Its redirect manager supports 301, 302, 307, 308, 410, and 451, with named and catch-all segments. The 404 log records up to 10,000 missed paths so you can fix them after launch. Carry the metadata Enable SEO on each collection. The per-entry panel holds the SEO title, meta description, canonical URL, social image, and a noindex switch. <EmDashHead> renders the canonical, Open Graph, Twitter Card, robots, and JSON-LD BlogPosting or WebSite graph. If you imported Yoast or Rank Math values through the exporter, they arrive as content fields, not SEO panel values. Your template has to render them. Check a sample of posts after the build. Sitemaps and robots EmDash serves a sitemap index at /sitemap.xml and child sitemaps at /sitemap-{collection}.xml. Each child holds up to 2,000 entries and continues on numbered pages. Collections with routing disabled stay out. robots.txt allows crawlers, disallows /_emdash/, and references the sitemap. Submit the new sitemap to Google Search Console and Bing Webmaster Tools before you remove the old one. Keep the old sitemap available during the transition so crawlers can compare. Images and internal links The importer rewrites media URLs when it downloads attachments. Check inline images, srcsets, Open Graph images, and any image URLs stored in custom fields. Internal links inside Portable Text may still point at the old domain or old slugs. Crawl the new site and fix them before launch. Do not change domains at the same time If you are also moving from example.com to www.example.com or a new domain, do it as a separate step. A platform migration and a domain migration together make it impossible to tell which change caused a ranking drop. Stage, freeze, cut over, roll back !A horizontal timeline of five cutover gates with an orange Cloudflare cloud at the DNS gate and a return arrow below 1. Lower DNS TTL. Set it to 300 seconds at least 48 hours before cutover. Our DNS records and propagation guide explains propagation and record choices. 2. Deploy a preview. Use Worker preview URLs or a hosts-file override. Test the full site before touching production DNS. 3. Freeze content. Stop publishing and editing on WordPress, then run the final import and media sync. 4. Switch DNS. Point the domain at the Worker. Keep the WordPress site running on its old host or a temporary hostname. 5. Verify immediately. Check the homepage, a sample of posts, all redirects, forms, auth, email, feed, sitemap, robots, and checkout if you have one. 6. Watch for 72 hours. Monitor 404s, server logs, Search Console, Bing, and conversion events. 7. Roll back if needed. DNS back to WordPress, database restored from the pre-cutover backup. Keep WordPress available for at least 30 days. The cutover is not the end. The first month is when redirect gaps, missing images, and broken forms show up. Performance and caching EmDash server-renders pages and queries D1 on request. That is flexible, but it is not automatically fast. - Enable Workers Cache with the Astro Cloudflare cache provider. Cached requests are served without running the Worker. - Add the KV object cache to reduce repeated D1 reads for content and settings. - Use Targeted Placement to run the Worker near the D1 primary. EmDash makes several D1 round trips per request. - Add indexes for the fields your collections filter and sort on. - Serve media from R2 with image transformations or a CDN in front. - Keep interactive widgets as Astro islands. Do not ship the admin bundle to public pages. The deployment guide covers D1, R2, Cron Triggers, object cache, Workers Cache, custom domains, email, and preview environments in detail. For edge CDN tradeoffs, see our Cloudflare vs Fastly vs Bunny CDN comparison. Security, auth, and backups EmDash's security story is the sandbox. Plugins declare capabilities and run in isolated Workers, so a compromised plugin does not automatically own the whole site. That is a real improvement over unrestricted PHP, but it is not a substitute for patching and least privilege. - Use passkeys for admin accounts, then add GitHub, Google, or Microsoft providers as needed. - On Cloudflare, Cloudflare Access can replace EmDash login for protected routes. - Store secrets with Wrangler or the platform's secret manager, not in the repository. - Put WAF rules and rate limits in front of login, forms, and search. - Keep the admin path out of robots.txt and behind Access if possible. Backups need three pieces, not one: - Database: D1 Time Travel bookmark before any migration or destructive change, plus an offsite SQL dump if you need longer retention. The zero downtime database migrations guide covers the same discipline for schema changes. - Media: aws s3 sync from R2 to a bucket outside the production account. - Keys: back up EMDASH_ENCRYPTION_KEY and its rotation list separately. Database exports do not contain it. Test a restore into an empty environment. An untested backup is a hope, not a recovery plan. Testing checklist - [ ] Every old URL returns 200 or a single 301 to the right page. - [ ] Canonicals, titles, descriptions, and structured data match or improve. - [ ] Sitemap and robots return 200 and list the right URLs. - [ ] Forms deliver to the right inbox and show the right success state. - [ ] Email login, invites, and password recovery work. - [ ] Comments, if used, render and submit. - [ ] Search returns relevant results. - [ ] Feed and social images resolve. - [ ] Analytics and conversion events fire. - [ ] Core Web Vitals are equal or better on mobile. - [ ] Accessibility checks pass on the new templates. - [ ] 404s are logged and routed to a useful page. Common failure modes | Failure | Cause | Fix | | --- | --- | --- | | Imported blocks are empty | Server-rendered block had no saved HTML | Rebuild the block in Astro | | ACF relationships point at old IDs | JSON values keep WordPress IDs | Remap to EmDash entries | | Media downloads fail | Files are behind auth, deleted, or redirect privately | Make the source public during migration | | Exporter is blocked | Cloudflare bot protection challenge | Add the documented skip rule or pause Bot Fight Mode | | Everything is a draft | Source status mapping | Review and publish intentionally | | Rankings drop after launch | Redirects missing or URLs changed | Compare crawl before and after | | Scheduled posts do not publish | Cron Trigger or scheduled handler missing | Add both, deploy, check emdash doctor | | Admin is slow or locked out | Passkey or Access misconfiguration | Test with a second admin before cutover | A 30/60/90 plan Days 1 to 30: audit, back up, choose the import path, run a test import, and rebuild the five most important templates. Do not touch DNS. Days 31 to 60: port plugins, build redirects, migrate media, run the full QA crawl, and set up staging. Publish a few posts on EmDash to test the editorial workflow. Days 61 to 90: freeze, final import, cutover, monitor, fix 404s, and keep WordPress as a rollback target. After 30 days of stable traffic, retire the old host. Conclusion EmDash is a credible WordPress alternative for content sites, and the cost and security arguments are real. It is not a lift-and-shift. The importer handles content; you still rebuild the theme, replace the plugins, and protect the URLs. Migrate if you have a content-heavy site, Astro or TypeScript skills, and a reason beyond a small monthly saving. Wait if you run WooCommerce, depend on a page builder, or cannot afford a staging and rollback window. The worst outcome is a half-migrated site with broken redirects and no owner. If you are planning the move, start with the crawl and the plugin inventory. Those two artifacts tell you more about the project than any benchmark. Sources - Cloudflare: Introducing EmDash - EmDash: Migrate from WordPress - EmDash: Built-in SEO features - EmDash: Deploy to Cloudflare - EmDash: Why EmDash - EmDash GitHub repository - Cloudflare Workers pricing - Cloudflare D1 pricing - Cloudflare R2 pricing - InfoQ: Cloudflare details its EmDash migration - The Register: Cloudflare previews EmDash

  2. @CloudflareDev #EmDash is already a bit more prepared: emdash#4610

    1 like · 30 views
  3. If you want to see native support for @EmDashCMS on @Netlify see this discussion post. My own site currently uses EmDash on Netlify, so let's see if we can get it native in EmDash! emdash#4618

    3 likes · 139 views
  4. If you want to see native support for EmDash on Netlify, see this discussion post. My own site, nickyt.co, currently uses EmDash on Netlify, so let's see if we can get it native in EmDash! emdash#4618 #cms #astrojs #netlify

    4 likes
  5. EmDash で日本語検索が 0 件になる原因と、プラグインでの直し方 EmDash(Astro ベースの CMS)でブログを作ったら、日本語のサイト内検索がほとんど当たりませんでした。タイトルに「検索」と入っている記事を「検索」で探しても 0 件です。 この記事では、EmDash 本体のソースを見て 0 件になる理由を確かめ、それを直すプラグインのコードを順に説明します。 確認したバージョン:emdash 1.3.1 動かしている環境:Cloudflare Workers(無料プラン)+ D1 結論 EmDash の検索は SQLite の FTS5 で、トークナイザーの初期値は porter unicode61 unicode61 はスペースと記号で区切るので、日本語は句読点までが 1 つのトークンになる 本体の検索は各語に * を付けた前方一致なので、当たるのは「トークンの先頭から始まる語」だけ プラグインで、保存時に 2 文字ずつ(bigram)に切った文字列を検索対象のフィールドに書き込む 検索時は検索ワードを同じ規則で切り、FTS5 のフレーズとして本体の search() に渡す 索引は本体の FTS5 がそのまま作ります。プラグインは自前の索引を持ちません。 0 件になる理由 トークナイザーの初期値 FTS5 の表を作るところはこうなっています。 emdash/src/search/fts-manager.ts const DEFAULT_SEARCH_TOKENIZER: SearchTokenizer = "porter unicode61"; emdash/src/search/fts-manager.ts(抜粋) const columns = ["id UNINDEXED", "locale UNINDEXED", ...searchableFields].join(", "); await sql .raw(` CREATE VIRTUAL TABLE IF NOT EXISTS "${ftsTable}" USING fts5( ${columns}, tokenize='${tokenizer}' ) `) .execute(this.db); searchable: true にしたフィールドが、そのまま FTS5 の列になります。この点はあとで使います。 unicode61 はスペースと記号のところで区切ります。日本語にはスペースがないので、句読点までがひと続きのトークンになります。fts5vocab で索引の中身を見ると、こうなっていました。 元の文: EmDash の日本語全文検索の挙動を検証します。 トークン: emdash | の日本語全文検索の挙動を検証します 「検索」というトークンはどこにもありません。 本体の検索ワードの扱い 検索ワードは escapeQuery で FTS5 の式に変換されます。 emdash/src/search/query.ts(抜粋) // If already a quoted phrase, escape only interior quotes and preserve phrase syntax if (query.startsWith('"') && query.endsWith('"') && query.length >= 2) { const inner = query.slice(1, -1); return "${inner.replace(DOUBLE_QUOTE_PATTERN, '""')}"; } const escaped = query.replace(DOUBLE_QUOTE_PATTERN, '""'); if (allowOperators && FTS_OPERATORS_PATTERN.test(query)) { return escaped; } const terms = escaped.split(WHITESPACE_SPLIT_PATTERN).filter((t) => t.length > 0); if (terms.length === 0) { return ""; } return terms.map((t) => "${t}"*).join(" "); 普通の検索ワードは "検索"* という前方一致になります。トークンの先頭から始まる語なら当たりますが、文の途中にある語は当たりません。 この escapeQuery には分岐が 3 つあります。プラグインの検索側は、このうち上の 2 つを使います。 入力 結果 " で始まり " で終わる 1 つのフレーズとしてそのまま通す AND OR NOT NEAR を含む ほぼそのまま通す(" だけエスケープ) それ以外 語ごとに "語"* にして並べる trigram では足りない EmDash はトークナイザーに trigram も選べます(選べるのは porter unicode61、unicode61、trigram の 3 つ)。3 文字ずつに切る方式なので、2 文字以下の検索ワードは当たりません。 node:sqlite で本体と同じ形の表を作り、本体の escapeQuery と同じ処理を通して試した結果です。記事は 4 件です。「料金」はタイトルの「Cloudflare Workers の料金」と本文の「施設利用料金をまとめました」にあり、どちらもトークンの先頭ではありません。 検索ワード porter unicode61 trigram プラグイン 検索 0 件 0 件 1 件 料金 0 件 0 件 2 件 挙動(文の途中の語) 0 件 0 件 1 件 全文検索 0 件 1 件 1 件 の料金(トークンの先頭) 1 件 1 件 1 件 日本語の検索ワードは「料金」「予約」「採用」のような 2 文字が多いので、trigram でも使えません。trigram はコレクション単位で切り替わるので、英語の語形をそろえる処理(Porter のステミング)もなくなります。 この件は本家の issue にも上がっています(emdash-cms/emdash#2775)。 直し方の方針 トークナイザーはそのままにして、2 文字ずつに切った文字列を、検索対象のフィールドとして本体に渡すことにしました。 保存時: 日本語検索について → ǁ日本 ǁ本語 ǁ語検 ǁ検索 ǁ索に ǁにつ ǁつい ǁいて (search_cjk フィールドに書き込む) 検索時: 日本語 → "ǁ日本 ǁ本語"(この 2 つが隣り合っている、というフレーズ) unicode61 はスペースで区切るので、書き込んだ文字列は 2 文字ずつのトークンとして索引に入ります。検索ワードも同じ規則で切ってフレーズにすれば、「この文字がこの順で並んでいる」という意味になります。辞書なしで部分一致と同じ結果になります。 フィールドに書くだけなので、FTS5 への登録は本体のトリガーが同じトランザクションでやります。公開状態、ゴミ箱、ロケール、BM25 の並び順も本体の仕組みのままです。 ǁ(U+01C1)は目印です。理由はあとで書きます。 実装 プラグインは 3 つのファイルでできています。 ファイル 役割 src/tokenize.ts 切り方。保存側と検索側の両方がここを使う src/plain-text.ts Portable Text から文章を取り出す src/plugin.ts 保存時のフックと、検索ワードを変換するルート 以下のコードは実際のプラグインのものです。長いコメントは省いています。 1. スキーマに索引用のフィールドを足す 検索したいコレクションに、searchable: true のテキストフィールドを足します。 seed/seed.json(posts の fields の最後) { "slug": "search_cjk", "label": "Search index (Japanese)", "type": "text", "searchable": true } 注意することが 2 つあります。 最後に足す。 本体は検索結果の抜粋(snippet())を、最初の検索対象の列から作ります。索引の列が先に来ると、抜粋が ǁ日本 ǁ本語… になります プラグインからは作れない。 サンドボックス型のプラグインにはスキーマを書き換える権限がないので、自分で足します 2. 正規化 保存側と検索側で、同じ正規化をかけます。 src/tokenize.ts export const MARKER = "ǁ"; export function normalize(text: string): string { return ( text .replace(IGNORABLE, "") .replaceAll(MARKER, "") .normalize("NFKC") .toLowerCase() ); } IGNORABLE はゼロ幅文字と異体字セレクタです。残すと「渡邊󠄂さん」のような語が途中で切れます NFKC で、半角カナの テスト が テスト に、全角の 0123 が 0123 になります 目印の ǁ は入力から消します。検索ワードに ǁ だけを入れると、全部の記事に当たってしまうためです 3. 2 文字ずつに切る 文字を 1 つずつ見て、日本語(ひらがな、カタカナ、漢字)の続きと、それ以外の英数字の続きに分けます。 src/tokenize.ts const WORD_CHAR = /[\p{L}\p{N}_]/u; export function toTokenGroups(normalized: string): string[][] { const groups: string[][] = []; let cjk: string[] = []; let word: string[] = []; const flushCjk = () => { if (cjk.length === 1) { groups.push([cjk[0] as string]); } else if (cjk.length > 1) { const group: string[] = []; for (let i = 0; i + 1 < cjk.length; i++) { group.push((cjk[i] as string) + (cjk[i + 1] as string)); } groups.push(group); } cjk = []; }; const flushWord = () => { if (word.length > 0) groups.push([word.join("")]); word = []; }; for (const ch of normalized) { if (isCjk(ch.codePointAt(0) as number)) { flushWord(); cjk.push(ch); } else if (WORD_CHAR.test(ch)) { flushCjk(); word.push(ch); } else { flushCjk(); flushWord(); } } flushCjk(); flushWord(); return groups; } 日本語の続きは、1 文字ずつずらした 2 文字の組にします 英数字の続きは、1 語のまま残します 句読点、スペース、文字の種類が変わるところで、グループを切ります for...of で回しているのは、サロゲートペアの漢字を途中で割らないためです isCjk は文字コードの範囲で判定する関数です。ひらがな、カタカナ、CJK 統合漢字とその拡張、々 や ー などが入っています。・(U+30FB)は語の区切りなので、わざと外しています。 4. 索引の文字列にする src/tokenize.ts export function toIndexText(text: string): { text: string; truncated: boolean } { const normalized = normalize(text); const source = truncate(normalized, MAX_SOURCE_CHARS); return { text: toTokenGroups(source) .map((group) => group.map((token) => MARKER + token).join(" ")) .join( ${MARKER} ), truncated: source.length !== normalized.length, }; } 実際の出力です。 日本語検索について → ǁ日本 ǁ本語 ǁ語検 ǁ検索 ǁ索に ǁにつ ǁつい ǁいて 本社は東京、京都、大阪の三拠点です。 → ǁ本社 ǁ社は ǁは東 ǁ東京 ǁ ǁ京都 ǁ ǁ大阪 ǁ阪の ǁの三 ǁ三拠 ǁ拠点 ǁ点で ǁです 目印の ǁ が要る理由 目印には役割が 2 つあります。 1. グループの間に挟んで、フレーズが区切りをまたがないようにする。 「本社は東京、京都、大阪」を目印なしで切ると、東京 京都 が隣り合います。すると「東京都」("東京 京都")で検索したときに、「東京都」という語がない記事が当たります。グループの間に単独の ǁ を置くと、そこに 1 トークン分の位置ができるので、東京 と 京都 は隣り合わなくなります。 2. 本文の列のトークンと混ざらないようにする。 title や content の列も、unicode61 で切られて索引に入っています。「東京・京都・大阪」は ・ で区切られるので、本文の列には 東京 京都 が隣り合って入ります。トークンの頭に ǁ を付けておけば、本文の列のトークンとは別のものになります。 node:sqlite で試すと、目印なしでは「東京都」で上の記事が当たり、目印ありでは当たりませんでした。 ǁ は Unicode では文字(Letter)として扱われるので、unicode61 はこれを区切りにせず、トークンの一部として残します。普通の文章にはまず出てきません。 5. 保存時のフック content:beforeSave は保存の直前に呼ばれるフックです。書き換えた内容を返すと、その内容で保存されます。 src/plugin.ts(抜粋) const plugin: SandboxedPlugin = { hooks: { "content:beforeSave": { errorPolicy: "continue", timeout: 3000, handler: async (event, ctx) => { const { content, collection } = event; if (!ctx.schema) return; const fieldSlug = await resolveFieldSlug(ctx.settings); const schema = await ctx.schema.getCollection(collection); if (!schema) return; const target = schema.fields.find((field) => field.slug === fieldSlug); if (!target) return; if (!target.searchable) { ctx.log.warn( Field "${fieldSlug}" on "${collection}" is not marked searchable, so core will not index it. Japanese search is inactive for this collection., ); return; } const sources = schema.fields.filter( (field) => field.searchable && field.slug !== fieldSlug, ); if (sources.length === 0) return; const missing = sources.filter((field) => !Object.hasOwn(content, field.slug)); if (missing.length === sources.length && !event.isNew) return; const values = new Map<string, unknown>(); for (const field of sources) { if (Object.hasOwn(content, field.slug)) values.set(field.slug, content[field.slug]); } // (一部の項目だけを送る更新の扱いは省略。「できないこと」で書きます) const extracted = sources.map((field) => extractPlainText(values.get(field.slug))); const text = extracted .map((part) => part.text) .filter((part) => part.length > 0) .join("\n"); const indexed = toIndexText(text); const indexText = indexed.text; if (content[fieldSlug] === indexText) return; return { ...content, [fieldSlug]: indexText }; }, }, }, // routes は後述 }; やっていることを順に書きます。 設定から索引のフィールド名を読む(初期値は search_cjk) コレクションにそのフィールドがなければ何もしない。コレクションごとに使うかどうかを選べます フィールドが searchable でなければ、警告を出して何もしない それ以外の searchable なフィールドを全部取り出し、文章にしてつなぐ toIndexText で索引の文字列にして、索引のフィールドに入れて返す 前回と同じなら書き換えない 元にするのは searchable なフィールドだけです。このブログの posts では title と content が対象で、excerpt(抜粋)は searchable にしていないので入りません。 errorPolicy の初期値は "abort" で、フックでエラーが起きると保存が失敗します。検索のせいで記事が保存できなくなるのは困るので、"continue" にしています。エラーが起きても、検索の精度が落ちるだけです。 権限(emdash-plugin.jsonc の capabilities)は content:write と schema:read の 2 つです。ctx.schema は schema:read がないと渡されないので、最初に確かめています。 ! content フィールドは Portable Text(JSON)です。extractPlainText で文章だけを取り出しています。本体も FTS5 に入れるときに SQL で text alt caption code のキーだけを取り出しているので、同じキーにそろえています。 1 つのブロックの中の span は、何もはさまずにつなぎます。「料金表について」のように一部を太字にすると span が分かれますが、間にスペースを入れると「料金表」の途中で区切れて当たらなくなるためです。ブロックどうしはスペースでつなぎます。 6. 検索ワードを FTS5 の式にする src/tokenize.ts export function toMatchExpression(query: string): string | null { const limited = normalize(truncate(query.slice(0, MAX_QUERY_CHARS * 4), MAX_QUERY_CHARS)); if (!hasCjk(limited)) return null; const groups = toTokenGroups(limited); if (groups.length === 0) return null; const mark = (group: string[]) => group.map((t) => MARKER + t); if (groups.length === 1) { const group = groups[0] as string[]; if (isLoneCjkChar(group)) return MARKER + group[0]; return "${mark(group).join(" ")}"; } return groups .map((group) => isLoneCjkChar(group) ? ${MARKER}${group[0]}* : mark(group).join(" + "), ) .join(" AND "); } 実際の出力です。 検索ワード 返す式 検索 "ǁ検索" 日本語 "ǁ日本 ǁ本語" 料金 プラン ǁ料金 AND ǁプラ + ǁラン EmDash 検索 ǁemdash AND ǁ検索 税 ǁ税 search null 本体の escapeQuery を通る形にしているのがポイントです。 グループが 1 つなら "…" で囲む。escapeQuery の 1 つ目の分岐で、1 つのフレーズとしてそのまま通ります グループが 2 つ以上なら、グループの中を +(隣り合う)、グループの間を AND でつなぐ。AND を含むので、2 つ目の分岐でそのまま通ります。"…" "…" のように並べると、1 つ目の分岐で全体が 1 つのフレーズにまとめられ、何も当たらなくなります 1 文字だけのときは組が作れないので、前方一致にする。単独なら ǁ税 を返し、本体が "ǁ税"* にします 日本語を含まないときは null を返す。英語の検索は本体にそのまま任せるので、ステミングも前方一致もこれまでどおりです ! 語ごとに search() を呼んで結果を突き合わせる方法は使えません。search() はそれぞれ並べ替えて limit で切るので、2 つの上位 30 件が重ならず、全部の語を含む記事があっても 0 件になることがあります。検索ワード全体を 1 つの式にして、search() を 1 回だけ呼びます。 7. 検索ページ Astro の検索ページでは、変換した式を search() に渡します。 src/pages/search.astro(抜粋) --- export const prerender = false; import { search } from "emdash"; import { sanitizeQuery, toMatchExpression } from "../../plugins/search-ja/src/tokenize"; const query = Astro.url.searchParams.get("q")?.trim() || ""; const SEARCH_OPTIONS = { collections: ["posts"], limit: 30 } as const; async function runSearch(q: string) { const match = toMatchExpression(q); const { items } = await search(match ?? sanitizeQuery(q), SEARCH_OPTIONS); return items; } const results = query ? await runSearch(query) : []; --- match が null(日本語なし)のときも、sanitizeQuery で ǁ を消してから渡します。ǁ がそのまま本体に届くと "ǁ"* になり、すべての記事に当たるためです。 プラグインには、同じ変換をする公開ルート(/_emdash/api/plugins/search-ja/query?q=...)もあります。最初は検索ページからこのルートを fetch していました。ところが Cloudflare Workers の本番(カスタムドメイン)では、Worker から自分のドメインへの fetch が失敗し、毎回本体の検索に落ちていました。ローカルでは動くので気づきにくいところです。今は上のように、変換の関数を直接呼んでいます。 組み込み方 このプラグインは npm には公開していません。プロジェクトの中に置いて、file: で依存に入れています。 package.json(抜粋) "dependencies": { "emdash": "1.3.1", "search-ja": "file:plugins/search-ja" } astro.config.mjs では、sandboxed ではなく plugins に入れています。 astro.config.mjs(抜粋) import emdash from "emdash/astro"; import searchJa from "search-ja"; emdash({ // ... plugins: [searchJa], }), sandboxed に入れるには、サンドボックスを動かすための Worker Loader が必要で、これは Cloudflare の有料プランでしか使えません。plugins に入れると、プラグインは隔離されずにホストと同じ権限で動きます。宣言した権限のチェックは効きます。自分で書いてコードを確認しているプラグインなので、これでよしとしています。 入れたあとは、すでにある記事を保存し直します。 フックは保存のときにしか動かないので、入れる前の記事には索引がありません。 できないこと 語の区切りはわからない。 文字列が含まれているかで探すので、「京都」で「東京都」も当たります 1 文字の検索は取りこぼす。 「税」は「税込」のように組の先頭にあれば当たります。「対象は課税。」のように句点の直前にあると、税 は 課税 の組の後ろにしかないので当たりません 表記ゆれはそろえない。 「出来る」と「できる」、「解約する」と「解約します」は別物です。辞書が必要なので、やっていません 長音は片方向だけ。 本文が「サーバー」なら「サーバ」で当たりますが、本文が「サーバ」だと「サーバー」では当たりません ・ で区切れる。 「サンテグジュペリ」で「サン・テグジュペリ」は当たりません データが増える。 索引の文字列は、日本語の本文の 3〜4 倍くらいになります 日本語を含まない検索ワードは正規化しない。 本体にそのまま渡すので、0123 で 0123 は当たりません 索引のフィールドが管理画面に見える。 サンドボックス型のプラグインからは隠しフィールドを作れないので、編集画面に ǁ日本 ǁ本語… が見えます 下書きのある記事を一部だけ更新すると、索引が更新されない。 下書きは revisions の表にあり、そこを読むには content:revisions:read の権限が要ります。権限を増やさないために、送られてこなかった項目がある更新では索引を作り直さず、前回の索引を残します。API から更新するときは、検索対象のフィールドを全部送ります ちゃんとした日本語検索がほしいなら、形態素解析や外部の検索サービスを使うことになります。この方法は、サービスを増やさずに、本体の FTS5 のままで日本語を当たるようにするためのものです。個人ブログの規模なら足りています。 まとめ EmDash の検索は FTS5 + porter unicode61 で、日本語は句読点までが 1 トークンになる 本体の検索は前方一致なので、文の途中の語は当たらない。trigram は 2 文字の語に当たらない 保存時に 2 文字ずつの組を検索対象のフィールドに書き、検索時は同じ規則でフレーズにする 目印の ǁ で、区切りをまたぐ誤ヒットと、本文の列との混ざりを防ぐ 本体の escapeQuery の分岐に合わせて、1 グループなら "…"、複数なら + と AND で 1 つの式にする このプラグインを作った経緯や、運用の話はブログに書いています。 EmDash で日本語検索が 0 件になるので、プラグインで直した ブログ:ideal-code-life.com

  6. YouTube · · YouTube description · CodeAZ · emdash-cms/emdash

    Free Self‑Hosted CMS That Beats Paid Builders Discover how emdash-cms/emdash lets you launch a professional site without monthly fees. Try it now, and follow for more open‑source tech hacks! emdash-cms/emdash: emdash-cms/emdash #emdash #nocode #opensource #cms #webdev

    1 like · 145 views
  7. github.com/emdash-cms/emdash/blob/main/skills/wor… こんなのあるんだ、みんなで使おう

    4 likes · 316 views
  8. @nakamu_freenote Workersで動いてるEmDash使ってればセキュリティ担保されてて良いよね、ならまだ分かるけど、EmDashのGit見たら"node": ">=22.16" 先日のNode.jsの脆弱性影響範囲内なんだよなあ github.com/emdash-cms/emdash/blob/main/package.js… nodejs.org

    1 like · 2,212 views
  9. Reddit · · r/BestGitHubRepos · u/amootiranian · emdash-cms/emdash

    EmDash: A full-stack TypeScript CMS built on Astro, the spiritual successor to WordPress (14k stars) What it does All text below is quoted directly from the project README. >A full-stack TypeScript CMS built on Astro. EmDash takes the ideas that made WordPress dominant -- extensibility, admin UX, a plugin ecosystem -- and rebuilds them on serverless, type-safe foundations. Plugins run in sandboxed Worker isolates, solving the fundamental security problem with WordPress's plugin architecture. Features: - Content -- Blog posts, pages, custom content types. Rich text editing via TipTap with Portable Text storage. Revisions, drafts, scheduled publishing, full-text search (FTS5), inline visual editing. - Admin -- Full admin panel with visual schema builder, media library (drag-drop uploads via signed URLs), navigation menus, taxonomies, widgets, and a WordPress import wizard. - Auth -- Passkey-first (WebAuthn) with OAuth and magic link fallbacks. Role-based access control: Administrator, Editor, Author, Contributor. - Plugins -- definePlugin() API with lifecycle hooks, KV storage, settings, admin pages, dashboard widgets, custom block types, and API routes. Sandboxed execution on Cloudflare via Dynamic Worker Loaders, and on Node.js in a workerd child process. - Agents -- Agent skills for AI-assisted site, plugin, and theme development. CLI for programmatic site management. Built-in MCP server for direct AI tool integration. - WordPress migration -- Import posts, pages, media, and taxonomies from WXR exports, the WordPress REST API, or WordPress.com. Agent skills help port plugins and themes. >npm create emdash@latest Repository emdash-cms/emdash Repository stats |Metric|Value| |:-|:-| |Stars|13,844| |Forks|1,320| |Last release|@emdash-cms/plugin-test@0.2.10 (2026-10-18)| |Languages|TypeScript 94.5%, Astro 4.3%, CSS 0.6%| |Contributors|217| |AI contributor accounts (Claude / Codex / Cursor / Copilot)|Copilot: 21, cursoragent: 4, claude: 180| |AI co-authored commits|Claude: 182, Codex: 1, Cursor: 4, Copilot: 21| Main contributor |Field|Value| |:-|:-| |Name|Matt Kane| |GitHub|@ascorbic| |Commits to this repo|981 (top contributor)| |Bio|Astro core maintainer. Created EmDash and Unpic.| |Company / Location|@cloudflare / UK| |X / Twitter|@ascorbic| |Followers|1458| |Public repos|332| |On GitHub since|2010-03-01| ────────────────────────────── 📌 How many great GitHub repos have you discovered and completely forgotten about? Don't let another useful tool disappear into your Reddit feed. Get the community's best repos, organized by category, plus key takeaways from real developer discussions, delivered to your inbox twice a week. 📬 Never lose track of great repos again. Subscribe free.

    5 points
  10. I have this section, which helps. Some ignore it, but the reviewer bots will usually catch violations. github.com/emdash-cms/emdash/blob/main/AGENTS.md

    1 like

Achievements

Awards earned by the repositories emdash-cms owns. Select an award to embed its badge.

No awards yet

Repositories earn awards by reaching the top three in a completed trending day or week.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.