บทเรียน 137 · Rails · Day 4 Part 1

Action Text — ช่องเนื้อหาแบบ rich text ที่มากับ Rails

~30 นาที (บทนี้ติดตั้งของจริงและแก้ 10 กว่าไฟล์ — ยาวกว่าบทปกติ) · ควรผ่าน บทเรียน 136 (allowlist · sanitize ตอนโชว์) · บทเรียน 109 (expand-contract) และ บทเรียน 125 (XSS · N+1 ใน view) มาก่อน

บท 136 เราประกอบ rich text เองด้วยมือเพื่อดูกลไก · บทนี้ติดตั้งของสำเร็จรูปที่มากับ Rails ชื่อ Action Text ให้ช่องเนื้อหาของบทความมีปุ่ม B ลิงก์ และรายการ
อ่านจบบทนี้ คุณจะติดตั้ง Action Text ได้ · รู้ว่าเนื้อหาไปอยู่ตารางไหน และทำไมค้นด้วย WHERE body ไม่ได้ · ย้ายเนื้อหา 30 บทความเดิมมาครบโดยไม่ลบของเก่า (expand) · และกัน N+1 ตัวใหม่ที่ Action Text แอบพามา — จบบทด้วย commit

🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้

🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 · action_text-trix 2.1.19 (มากับ Rails แล้ว ไม่ต้องติดตั้ง gem เพิ่ม) — สไลด์และ repo ของอาจารย์เขียน Ruby 4.0.5–4.0.6 · Rails 8.1.3 เรื่องในบทนี้ไม่ต่างกัน
📍 โค้ดเริ่มจาก: ตอนจบ Day 3 (branch my-day3) ที่คืนของทดลองบท 136 แล้ว (git status --short ว่าง) · หมวด 5 · บทความ 30 ชิ้น · บทนี้ทำของจริงแล้ว commit ตอนจบ
🖥 เปิดค้างไว้: docker compose up -d (ฐานข้อมูล) · bin/dev หนึ่งหน้าต่าง (เว็บที่ localhost:3000) · อีกหน้าต่างไว้พิมพ์คำสั่ง · ผลรันทุกกล่องมาจากเครื่องจริง · เวลา ms · วันเวลา · เลข 0x… · id · เลขหน้าชื่อไฟล์ migration ของคุณจะไม่ตรงกับในบท

Action Text คืออะไร — และบทนี้จะทำ 6 ขั้น

Action Text คือชิ้นส่วนที่มากับ Rails ให้ช่องเนื้อหามีปุ่มจัดรูปแบบ (ตัวหนา ลิงก์ หัวข้อ รายการ) แล้วเก็บผลเป็น HTML ให้ · ตอนโชว์มัน sanitize ให้เองทุกครั้ง แบบที่บท 136 สอน · เราจึงไม่ต้องประกอบเองอีก

ช่องพิมพ์ที่มีปุ่มนั้นเรียกว่า editor · ตัวที่มากับ Action Text ชื่อ Trix (โปรแกรม JavaScript ที่วาดแถบปุ่มและช่องพิมพ์ในเบราว์เซอร์) · (ถ้าได้ยินชื่อ Lexxy — เป็น editor ตัวใหม่ที่เป็น gem แยก ไม่ได้ติดมากับ Rails 8.1.4 · บทนี้ใช้ Trix)

🎂 เทียบให้เห็นภาพ — ชุดแต่งหน้าเค้กสำเร็จรูป

บท 136 ร้านทำทุกอย่างเองด้วยมือ: ทำกระดาษให้ลูกค้าเขียนข้อความหน้าเค้ก · หาที่เก็บใบสั่ง · จดรายการตัวตกแต่งที่อนุญาต · ตรวจตอนจัดจาน · Action Text คือชุดสำเร็จรูปกล่องเดียวที่มีครบ: กระดาษพร้อมปุ่มตัวหนา (editor) · ลิ้นชักเก็บใบสั่ง (ตาราง) · คนตรวจตอนจัดจาน (sanitize)

ที่ไม่เหมือนกัน: ชุดสำเร็จรูปจริงแกะกล่องแล้วใช้ได้เลย · Action Text ยังต้องปรับให้เข้ากับร้านเรา — ย้ายเนื้อหา 30 บทความเดิมเข้าลิ้นชักใหม่ · จัด CSS ให้เข้ากับ Tailwind · กันการเดินไปลิ้นชักทีละแผ่น · ซึ่งก็คือเนื้อหาเกือบทั้งบทนี้

ขั้นทำอะไรไฟล์หลักที่แตะ
1ติดตั้ง Action Text + สร้างตารางinstaller สร้างให้
2บอก model ว่าบทความมีช่อง body แบบ rich textapp/models/post.rb
3เพิ่มคอลัมน์ plain_text + ให้ model เขียนทั้งช่องใหม่และช่องเก่า (expand)migration · post.rb
4ย้ายเนื้อหา 30 บทความเดิมจาก description ไป bodylib/tasks/posts.rake
5เปลี่ยนฟอร์มและหน้าแสดงผลไปใช้ body + จัด CSS ของ Trix_form · _post · controller · CSS
6กัน N+1 ในหน้ารวม + แก้ seeds + commitcontroller · development.rb · seeds.rb

เทียบกับของที่รู้: ฝั่ง Next.js (บท 69–78) ไม่มี editor แบบนี้ติดมา ต้องเลือก library มาเองแล้วต่อกับ backend เอง · Rails ให้มาครบทั้ง editor · ตารางเก็บข้อมูล · การ sanitize ตอนโชว์ — ติดตั้งด้วยคำสั่งเดียว

🗣 ศัพท์ที่เพิ่งเรียนไป
rich text editor (WYSIWYG editor)
ช่องพิมพ์ที่มีปุ่มจัดรูปแบบและเห็นผลทันที (WYSIWYG = what you see is what you get) · ผลที่ได้จริงคือ HTML · ถาม senior ได้ว่า "editor ของโปรเจกต์เราคือตัวไหน และ HTML ที่มันส่งมาถูก sanitize ที่ไหน"
Action Text · Trix
Action Text = ชิ้นส่วนของ Rails สำหรับ rich text (ตาราง + model + helper) · Trix = editor ที่มากับมัน · พิมพ์บอก AI ได้ว่า "use Rails Action Text with the default Trix editor; do not add another editor library"

ขั้น 1 · ติดตั้ง — bin/rails action_text:install

รันในหน้าต่างที่สอง (โฟลเดอร์โปรเจกต์) — ผลจริง:

$ bin/rails action_text:install
Installing JavaScript dependencies
      append  app/javascript/application.js
      append  config/importmap.rb
      create  app/assets/stylesheets/actiontext.css
      create  app/views/active_storage/blobs/_blob.html.erb
      create  app/views/layouts/action_text/contents/_content.html.erb
       rails  railties:install:migrations FROM=active_storage,action_text
Copied migration 20261110034320_create_active_storage_tables.active_storage.rb from active_storage
Copied migration 20261110034321_create_action_text_tables.action_text.rb from action_text
      invoke  test_unit
      create    test/fixtures/action_text/rich_texts.yml

ทีละบรรทัด (append = ต่อท้ายไฟล์เดิม · create = สร้างไฟล์ใหม่):

สองไฟล์ที่ถูก append — ดูด้วย git diff (ผลจริง ตัดหัว diff ออก):

# app/javascript/application.js
 import "@hotwired/turbo-rails"
 import "controllers"
+
+import "trix"
+import "@rails/actiontext"

# config/importmap.rb
 pin_all_from "app/javascript/controllers", under: "controllers"
+pin "trix"
+pin "@rails/actiontext", to: "actiontext.esm.js"

แล้วสร้างตาราง — ผลจริง:

$ bin/rails db:migrate
== 20261110034320 CreateActiveStorageTables: migrating ========================
-- create_table(:active_storage_blobs, {id: :primary_key})
   -> 0.0033s
-- create_table(:active_storage_attachments, {id: :primary_key})
   -> 0.0029s
-- create_table(:active_storage_variant_records, {id: :primary_key})
   -> 0.0018s
== 20261110034320 CreateActiveStorageTables: migrated (0.0095s) ===============

== 20261110034321 CreateActionTextTables: migrating ===========================
-- create_table(:action_text_rich_texts, {id: :primary_key})
   -> 0.0021s
== 20261110034321 CreateActionTextTables: migrated (0.0033s) ==================

หลังติดตั้งแล้ว ปิด bin/dev (Ctrl+C) แล้วเปิดใหม่ หนึ่งรอบ ให้แน่ใจว่าเว็บรู้จักไฟล์ JavaScript ที่เพิ่งเพิ่ม

ขั้น 2 · เนื้อหาไม่ได้อยู่ในตาราง posts

เปิด db/schema.rb (ไฟล์ที่สรุปโครงทุกตาราง — บท 108) หาตารางใหม่:

  create_table "action_text_rich_texts", force: :cascade do |t|
    t.string "name", null: false
    t.text "body"
    t.string "record_type", null: false
    t.bigint "record_id", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true
  end

ต่างจากที่เคยเห็นตรงไหน? ตาราง comments ของเรามีคอลัมน์ post_id (บท 115) — รู้อยู่แล้วว่าชี้ไปตาราง posts · ตารางนี้ชี้ได้ทุกตาราง จึงต้องเก็บสองช่อง: ชื่อ model (record_type) กับเลขแถว (record_id) · การชี้แบบนี้เรียกว่า polymorphic (แปลตรง ๆ ว่า "รับได้หลายแบบ")

คำว่า polymorphic เคยผ่านตาในชื่อ polymorphic_path (บท 117) แต่การเก็บแบบสองช่องนี้เพิ่งเจอครั้งแรก · บท 138 จะเจออีกที่ตาราง active_storage_attachments ซึ่งมี record_type + record_id แบบเดียวกัน

🗄 เทียบให้เห็นภาพ — ลิ้นชักกลาง "ข้อความยาว" ใบเดียวของทุกแผนก

แฟ้มบทความในลิ้นชัก posts ไม่มีที่เขียนเนื้อหายาว ๆ แล้ว · เนื้อหาไปอยู่ลิ้นชักกลาง "ข้อความยาว" ที่ทุกแผนกใช้ร่วมกัน · ทุกแผ่นในลิ้นชักกลางติดป้ายว่า "ของแผนกไหน · แฟ้มเลขที่เท่าไร · ช่องอะไร" เช่น "แผนกบทความ · แฟ้ม 5 · เนื้อหา"

ที่ไม่เหมือนกัน: ลิ้นชักจริง พนักงานเห็นป้ายแล้วรู้ว่าแผนกนั้นมีจริงไหม · ฐานข้อมูลตรวจไม่ได้ว่า record_type + record_id ชี้ไปแถวที่มีอยู่จริง (ไม่มี foreign key แบบ post_id — เพราะชี้ได้หลายตาราง) · Rails เป็นคนดูแลให้แทน เช่นลบบทความแล้วลบแผ่นในลิ้นชักกลางตามให้

บอก model ว่าบทความมีช่อง rich text ชื่อ body — เปิด app/models/post.rb เติม 2 บรรทัดใต้ has_many :comments (ส่วนอื่นยังไม่แตะ):

class Post < ApplicationRecord
  belongs_to :category
  has_many :comments, dependent: :destroy
  # เนื้อหาแบบ rich text เก็บในตาราง action_text_rich_texts (ไม่ได้อยู่ในตาราง posts)
  has_rich_text :body
  …
wongnok-blog-all-in-one(dev):001> post = Post.first
wongnok-blog-all-in-one(dev):002> post.body
wongnok-blog-all-in-one(dev):003> post.body.to_s

บทความเลข 1 มีเนื้อหาเรื่องต้มยำกุ้งยาวเกือบสองบรรทัดอยู่ในช่อง description · เพิ่ม has_rich_text :body แล้ว — post.body จะได้เนื้อหาต้มยำกุ้งไหม?

ผลจริง (เปิด bin/rails console):

wongnok-blog-all-in-one(dev):002> post.body
  ActionText::RichText Load (0.5ms)  SELECT "action_text_rich_texts".* FROM "action_text_rich_texts" WHERE "action_text_rich_texts"."record_id" = 1 AND "action_text_rich_texts"."record_type" = 'Post' AND "action_text_rich_texts"."name" = 'body' LIMIT 1 /*application='WongnokBlogAllInOne'*/
=>
#<ActionText::RichText:0x0000000129e2aa20
 id: nil,
 name: "body",
 body: nil,
 record_type: "Post",
 record_id: 1,
 created_at: nil,
 updated_at: nil>
wongnok-blog-all-in-one(dev):003> post.body.to_s
=> ""
wongnok-blog-all-in-one(dev):004> ActionText::RichText.count
  ActionText::RichText Count (2.4ms)  SELECT COUNT(*) FROM "action_text_rich_texts" /*application='WongnokBlogAllInOne'*/
=> 0
  • ไม่ได้ — SELECT … WHERE record_id = 1 AND record_type = 'Post' AND name = 'body' = ไปหาในลิ้นชักกลาง ไม่เจอ
  • id: nil · body: nil — Rails เลยหยิบกระดาษเปล่าที่ยังไม่ได้บันทึกมาให้ (ไม่ error) · to_s ได้ข้อความว่าง
  • RichText.count = 0 — ลิ้นชักกลางว่างทั้งลิ้นชัก · เนื้อหาเดิมยังอยู่ใน description ครบ แต่ไม่มีใครย้ายให้ → ขั้น 3–4 ทำเรื่องนี้
🗣 ศัพท์ที่เพิ่งเรียนไป
polymorphic association
ความสัมพันธ์ที่แถวหนึ่งชี้ไปได้หลายตาราง โดยเก็บ …_type (ชื่อ class) คู่กับ …_id · ไม่มี foreign key ในฐานข้อมูล · ถาม senior ได้ว่า "ตาราง polymorphic ตัวนี้ ใครรับผิดชอบลบแถวกำพร้าเวลาแถวแม่ถูกลบ"
has_rich_text
บอก model ว่ามีช่อง rich text · ได้ method ชื่อเดียวกับช่อง + scope with_rich_text_ชื่อช่อง (ขั้น 6) · พิมพ์บอก AI ได้ว่า "add has_rich_text :body to Post; do not add a body column to the posts table"

ขั้น 3 · แผนย้ายข้อมูล — expand ก่อน ยังไม่ลบ description

เนื้อหา 30 บทความอยู่ใน description ต้องย้ายไป body · นี่คือการย้ายข้อมูลจริงแบบที่บท 109 สอน · ข้อกำหนดของสไลด์หน้า 14 เขียนชัดว่า รอบนี้ห้ามลบคอลัมน์ description — เพิ่มของใหม่ก่อน ย้ายข้อมูล แล้วปล่อยของเก่าไว้ ค่อยลบรอบหน้า

🔨 เทียบให้เห็นภาพ — เปลี่ยนแบบฟอร์มโดยไม่โยนแฟ้มเก่าทิ้ง (ภาพเดียวกับบท 109)

ร้านเปลี่ยนมาเขียนเนื้อหาบนกระดาษในลิ้นชักกลาง · แต่ช่อง "เนื้อหา" บนแฟ้มเดิมยังอยู่ และสั่งพนักงานว่า "ช่วงนี้เขียนทั้งสองที่" · ถ้าระบบใหม่มีปัญหาแล้วต้องถอยกลับไปใช้คู่มือเก่า แฟ้มทุกแผ่นยังมีข้อมูลครบ · พอมั่นใจแล้วค่อยประกาศเลิกใช้ช่องเดิม แล้วลบรอบหน้า

ที่ไม่เหมือนกัน: พนักงานจริงลืมเขียนที่สองได้ · ในโค้ด "เขียนสองที่" คือ callback บรรทัดเดียวที่ทำให้ทุกครั้งที่บันทึก ไม่มีวันลืม · แต่ถ้ามีใครแก้ข้อมูลด้วย SQL ตรง ๆ (ไม่ผ่าน model) callback ก็ไม่ทำงาน — สองช่องจะไม่ตรงกัน

ของใหม่อีกชิ้นที่ต้องเพิ่มคือคอลัมน์ plain_text (ตัวหนังสือล้วน ไว้ค้นหา — บท 136) · คอลัมน์นี้อยู่ในตาราง posts ตามปกติ:

$ bin/rails generate migration AddPlainTextToPosts plain_text:text
      invoke  active_record
      create    db/migrate/20261110034341_add_plain_text_to_posts.rb
$ bin/rails db:migrate
== 20261110034341 AddPlainTextToPosts: migrating ==============================
-- add_column(:posts, :plain_text, :text)
   -> 0.0009s
== 20261110034341 AddPlainTextToPosts: migrated (0.0023s) =====================

ไฟล์ migration ที่ได้ไม่ต้องแก้ (add_column :posts, :plain_text, :text บรรทัดเดียว — ชื่อแบบ AddXxxToPosts ทำให้ generator เขียนให้เองตามบท 108) · เป็นคอลัมน์ใหม่ที่ว่างได้ ไม่มีค่าเริ่มต้น = ปลอดภัยตามกฎบท 109

แล้วแก้ model ทั้งไฟล์ เป็นแบบนี้ — app/models/post.rb:

class Post < ApplicationRecord
  belongs_to :category
  has_many :comments, dependent: :destroy
  # เนื้อหาแบบ rich text เก็บในตาราง action_text_rich_texts (ไม่ได้อยู่ในตาราง posts)
  has_rich_text :body

  validates :title, presence: true, length: { maximum: 120 }
  validates :body, presence: true

  # ทุกครั้งที่บันทึก: เก็บข้อความล้วนของ body ไว้ใน plain_text (ไว้ค้นหาทีหลัง)
  # และเขียนลง description ด้วย (expand-contract: คอลัมน์เก่ายังอยู่ เขียนทั้งสองช่องไปก่อน)
  before_save :copy_body_to_plain_text

  scope :recent, -> { order(created_at: :desc) }

  private
    def copy_body_to_plain_text
      self.plain_text = body.to_plain_text
      self.description = plain_text
    end
end
🗣 ศัพท์ที่เพิ่งเรียนไป
dual write (เขียนสองที่)
ช่วง expand ที่โค้ดเขียนค่าลงทั้งคอลัมน์ใหม่และคอลัมน์เก่า เพื่อให้ถอยโค้ดกลับได้โดยข้อมูลไม่ขาด · ถาม senior ได้ว่า "เราจะเลิก dual write แล้วลบคอลัมน์เก่าเมื่อไหร่ ใครเป็นคนตัดสิน"
to_plain_text
method ของ rich text ที่คืนตัวหนังสือล้วน (ไม่มีแท็ก) ไว้ค้นหา ตัดคำโปรย หรือส่งไปที่ที่ไม่ใช่หน้าเว็บ · พิมพ์บอก AI ได้ว่า "keep a plain_text column filled from body.to_plain_text in a before_save callback"

ขั้น 4 · ย้าย 30 บทความเดิมด้วย rake task

กฎจากบท 109: การย้ายข้อมูลอยู่ในงานแยก ไม่ใช่ในไฟล์ migration · งานแยกแบบหนึ่งคือ rake task = คำสั่งที่เราเขียนเพิ่มเอง แล้วรันด้วย bin/rails ชื่องาน · ให้ generator สร้างโครงให้ (posts = กลุ่มของงาน · backfill_body = ชื่องาน):

$ bin/rails generate task posts backfill_body
      create  lib/tasks/posts.rake

ไฟล์ที่ได้เป็นงานเปล่า ๆ (desc "TODO" + task backfill_body: :environment do … end) · เขียนทับทั้งไฟล์เป็น — lib/tasks/posts.rake:

namespace :posts do
  desc "ย้าย description (ข้อความล้วน) ไปเป็น body (rich text) ให้บทความที่ยังไม่มี body — รันซ้ำได้"
  task backfill_body: :environment do
    # เลือกเฉพาะบทความที่ยังไม่มีแถวใน action_text_rich_texts → รันรอบสองจะไม่ทับของที่ย้ายแล้ว
    Post.where.missing(:rich_text_body).find_each do |post|
      # escape ก่อน: ถ้าใน description มีคำว่า <script> ให้โชว์เป็นตัวอักษร ไม่กลายเป็นแท็ก
      text = ERB::Util.html_escape(post.description)
      # ขึ้นบรรทัดใหม่ → <br> แบบเดียวกับที่ Trix เก็บ
      post.update!(body: "<div>#{text.gsub(/\r?\n/, "<br>")}</div>")
    end

    puts "บทความทั้งหมด #{Post.count} · ยังไม่มี body #{Post.where.missing(:rich_text_body).count}"
  end
end

เทียบกับของที่รู้: rake task คล้าย "scripts" ใน package.json ฝั่ง Next.js ที่สั่งด้วย npm run ชื่อ — เป็นงานที่ตั้งชื่อไว้ แล้วใครในทีมก็สั่งด้วยชื่อเดียวกันได้ · ต่างกันตรงที่ rake task โหลดแอปทั้งตัว (:environment) จึงใช้ model ได้เลย

🗂 เทียบให้เห็นภาพ — ใบคำสั่งงานพิเศษที่ตั้งชื่อไว้

หัวหน้าเขียนใบคำสั่ง "ย้ายเนื้อหาลงลิ้นชักกลาง" เก็บไว้ในแฟ้มงานพิเศษของร้าน · ใครจะสั่งก็เรียกชื่อเดียว พนักงานทำตามขั้นที่เขียนไว้เหมือนกันทุกครั้ง · ในใบเขียนไว้ด้วยว่า "ข้ามแฟ้มที่มีแผ่นในลิ้นชักกลางแล้ว" ใครเผลอสั่งซ้ำก็ไม่เกิดเรื่อง

ที่ไม่เหมือนกัน: พนักงานจริงเห็นแฟ้มที่ย้ายแล้วก็รู้เองว่าข้ามได้ · rake task รู้แค่ที่เราเขียน — where.missing คือบรรทัดที่ทำให้มันข้าม · ถ้าลืมบรรทัดนี้ รอบสองจะเอา description (ตัวหนังสือล้วน) ไปทับ body ที่ผู้ใช้จัดตัวหนาไว้แล้ว

🗣 ศัพท์ที่เพิ่งเรียนไป
rake task · backfill
rake task = คำสั่งที่เราเขียนเพิ่มเองใน lib/tasks/*.rake · backfill = เติมข้อมูลให้แถวเก่าหลังเพิ่มของใหม่ · พิมพ์บอก AI ได้ว่า "write the backfill as an idempotent rake task (safe to run twice), not inside the migration"

ก่อนรัน ดูสถานะใน console — ผลจริง:

wongnok-blog-all-in-one(dev):001> Post.where.missing(:rich_text_body).count
  Post Count (0.8ms)  SELECT COUNT(*) FROM "posts" LEFT OUTER JOIN "action_text_rich_texts" "rich_text_body" ON "rich_text_body"."record_type" = 'Post' AND "rich_text_body"."name" = 'body' AND "rich_text_body"."record_id" = "posts"."id" WHERE "rich_text_body"."id" IS NULL /*application='WongnokBlogAllInOne'*/
=> 30
wongnok-blog-all-in-one(dev):002> Post.where(plain_text: nil).count
  Post Count (0.6ms)  SELECT COUNT(*) FROM "posts" WHERE "posts"."plain_text" IS NULL /*application='WongnokBlogAllInOne'*/
=> 30

ออกจาก console (exit) แล้วรันงานสองรอบติดกัน — ผลจริง:

$ bin/rails posts:backfill_body
บทความทั้งหมด 30 · ยังไม่มี body 0
$ bin/rails posts:backfill_body
บทความทั้งหมด 30 · ยังไม่มี body 0

รอบสองได้บรรทัดเดิม ไม่พัง ไม่สร้างซ้ำ · ตรวจใน console อีกที — ผลจริง:

wongnok-blog-all-in-one(dev):001> Post.where.missing(:rich_text_body).count
  …
=> 0
wongnok-blog-all-in-one(dev):002> Post.where(plain_text: nil).count
  …
=> 0
wongnok-blog-all-in-one(dev):003> ActionText::RichText.count
  ActionText::RichText Count (2.7ms)  SELECT COUNT(*) FROM "action_text_rich_texts" /*application='WongnokBlogAllInOne'*/
=> 30
wongnok-blog-all-in-one(dev):004> ActionText::RichText.first
  …
=>
#<ActionText::RichText:0x000000012cc315b8
 id: 1,
 name: "body",
 body: #<ActionText::Content "<div>ต้มยำกุ้งแบบน้ำข้...">,
 record_type: "Post",
 record_id: 1,
 created_at: "2026-11-10 10:43:59.970055000 +0700",
 updated_at: "2026-11-10 10:43:59.970055000 +0700">

📝 ผมลองใส่บทความทดสอบที่ description เป็น บรรทัดแรก + ขึ้นบรรทัดใหม่ + <b>หนา</b> & จบ แล้วรันงานนี้ · body ที่ได้คือ <div>บรรทัดแรก<br>&lt;b&gt;หนา&lt;/b&gt; &amp; จบ</div> = แท็กกลายเป็นตัวอักษร (escape แล้ว) และขึ้นบรรทัดใหม่เป็น <br> · ลบบทความทดสอบทิ้งแล้ว

ขั้น 5 · ฟอร์มและหน้าแสดงผล

ตอนนี้ข้อมูลพร้อม แต่หน้าเว็บยังใช้ description อยู่ · แก้ 3 ไฟล์

① ฟอร์ม — app/views/posts/_form.html.erb ก้อนช่องเนื้อหา (ก้อน <div class="my-5"> ที่สอง) เปลี่ยนเป็น:

  <div class="my-5">
    <%= form.label :body, "เนื้อหา", class: "block font-medium" %>
    <%# rich_textarea = ช่องพิมพ์ของ Trix (ตัวหนา ลิงก์ หัวข้อ) ส่งค่าเป็น HTML ไปที่ post[body] %>
    <%= form.rich_textarea :body, class: ["mt-2 min-h-40", {"border-error": post.errors[:body].any?}] %>
  </div>

② controller — app/controllers/posts_controller.rb บรรทัดท้ายไฟล์ (รายชื่อช่องที่รับ):

      params.expect(post: [ :title, :body, :category_id ])

เปลี่ยน :description เป็น :body · ลืมบรรทัดนี้ = พิมพ์เนื้อหาแล้วยังขึ้น Body can't be blank (ลองแล้ว) เพราะ controller ทิ้งช่อง body ก่อนถึง model

③ แม่พิมพ์บทความ — app/views/posts/_post.html.erb แก้ 2 จุด:

      <%# Action Text ล้าง HTML อันตราย (sanitize) ให้เองตอนแสดง — ห้ามใส่ raw/html_safe %>
      <div class="leading-relaxed"><%= post.body %></div>
  …
      <p class="text-base-content/80"><%= truncate(post.body.to_plain_text, length: 80, separator: " ") %></p>

เปิด localhost:3000/posts/new · ดู HTML ที่เบราว์เซอร์ได้รับ — ผลจริง (บรรทัดเดียวยาว ๆ):

$ curl -s localhost:3000/posts/new | grep -n 'trix-editor'
85:    <input type="hidden" name="post[body]" id="post_body_trix_input_post" /><trix-editor class="mt-2 min-h-40" id="post_body" input="post_body_trix_input_post" data-direct-upload-url="http://localhost:3000/rails/active_storage/direct_uploads" data-blob-url-template="http://localhost:3000/rails/active_storage/blobs/redirect/:signed_id/:filename"></trix-editor>

ผมพิมพ์ "ร้านนี้ อร่อยมาก แนะนำ" (กดปุ่ม B ตรงคำกลาง) ใน Chrome จริง · ช่องซ่อนได้ค่า <div>ร้านนี้&nbsp;<strong>อร่อยมาก</strong> แนะนำ</div> · กดบันทึกแล้วหน้าบทความโชว์ตัวหนาจริง · (&nbsp; = ช่องว่างแบบที่ Trix ใส่บางจุด ไม่ต้องสนใจ)

CSS ของ Trix ต้องย้ายเข้า Tailwind

installer วาง actiontext.css ไว้ที่ app/assets/stylesheets/ · ผมลองปล่อยไว้ที่เดิม แล้ววัดใน Chrome: ช่องพิมพ์สูงแค่ 80px (ไม่สน min-h-40) · และลิงก์ในเนื้อหาสีเดียวกับตัวหนังสือ ไม่มีขีดเส้นใต้ มองไม่ออกว่าเป็นลิงก์

สาเหตุมีสองข้อ · ① ไฟล์นี้ไม่ได้อยู่ใน "ชั้น" ของ Tailwind จึงชนะคลาส Tailwind ทุกตัว · ② Tailwind ล้างหน้าตาของหัวข้อ / รายการ / ลิงก์ทิ้งทั้งเว็บตั้งแต่ต้น (บท 128 ใช้คลาสแทน) — เนื้อหาที่ผู้ใช้จัดมาจึงโชว์แบนหมด

แก้ด้วยการย้ายไฟล์ แล้วสั่งให้ Tailwind โหลดเข้า "ชั้นของ component":

$ mv app/assets/stylesheets/actiontext.css app/assets/tailwind/actiontext.css

app/assets/tailwind/application.css ทั้งไฟล์:

@import "tailwindcss";
/* ไฟล์ CSS ของ Trix ที่ action_text:install สร้าง (ย้ายมาจาก app/assets/stylesheets)
   ใส่ไว้ใน layer components → utility class อย่าง min-h-40 / border-error ยังทับได้ */
@import "./actiontext.css" layer(components);
@source not "./daisyui{,*}.mjs";
@plugin "./daisyui.mjs" {
  themes: light --default, dark --prefersdark;
}

@theme {
  --font-sans: "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
}

/* Tailwind ล้าง style ของหัวข้อ / list / ลิงก์ทิ้งทั้งเว็บ → คืนให้เฉพาะในเนื้อหา rich text */
.trix-content h1 { font-weight: 700; }
.trix-content ul { list-style: disc; }
.trix-content ol { list-style: decimal; }
.trix-content a { color: var(--color-primary); text-decoration: underline; }

/* ไอคอนบนแถบเครื่องมือของ Trix เป็นสีดำตายตัว → ให้พื้นขาวเสมอ ธีมมืดจะได้ยังมองเห็น */
trix-toolbar .trix-button-group { background-color: white; }
trix-toolbar .trix-dialog { color: black; }

bin/dev สร้าง CSS ใหม่ให้เอง · วัดซ้ำใน Chrome: ช่องพิมพ์ 160px · ลิงก์เป็นสี primary + ขีดเส้นใต้

Action Text ล้างของอันตรายให้ตอนโชว์

wongnok-blog-all-in-one(dev):001> post = Post.create!(title: "ทดลอง rich text", category: Category.first, body: %(<div>ร้านนี้<strong>อร่อยมาก</strong> <a href="/posts" onclick="alert(1)">ดูร้านอื่น</a><script>alert(1)</script></div>))

สร้างบทความที่เนื้อหามีทั้งของดี (<strong> · ลิงก์) และของร้าย (onclick · <script>) โดยไม่ผ่านหน้าเว็บ · (%(…) = อีกวิธีเขียนข้อความใน Ruby ใช้เมื่อข้างในมี " เยอะ · alert(1) แค่เด้งกล่อง ไม่ทำอะไรเสียหาย) · คิดว่าในฐานข้อมูลจะเก็บ <script> ไว้ไหม · แล้วหน้าเว็บจะส่งอะไรออกไป?

log ตอนบันทึก (ผลจริง ตัดเหลือบรรทัดที่สำคัญ):

  ActionText::RichText Create (0.4ms)  INSERT INTO "action_text_rich_texts" ("name", "body", "record_type", "record_id", "created_at", "updated_at") VALUES ('body', '<div>ร้านนี้<strong>อร่อยมาก</strong> <a href="/posts" onclick="alert(1)">ดูร้านอื่น</a><script>alert(1)</script></div>', 'Post', 31, …)
…
wongnok-blog-all-in-one(dev):002> post.id
=> 31
wongnok-blog-all-in-one(dev):003> post.plain_text
=> "ร้านนี้อร่อยมาก ดูร้านอื่น"

ฐานข้อมูลเก็บทุกตัวอักษร รวม onclick และ <script> · ทีนี้ดูหน้าเว็บ (ผลจริง · ผมตัดเส้นทางยาวของไฟล์ใน gem เป็น …):

$ curl -s localhost:3000/posts/31 | grep -n -A3 'trix-content'
95:--><div class="trix-content">
96-  <!-- BEGIN …/actiontext-8.1.4/app/views/action_text/contents/_content.html.erb
97---><div>ร้านนี้<strong>อร่อยมาก</strong> <a href="/posts">ดูร้านอื่น</a>alert(1)</div>
98-<!-- END …/actiontext-8.1.4/app/views/action_text/contents/_content.html.erb --></div>
  • grep -A3 = เอาบรรทัดที่เจอ + อีก 3 บรรทัดถัดไป (บรรทัดที่ตามมามีขีด - แทน : หลังเลข)
  • บรรทัด 95 — <div class="trix-content"> มาจาก layouts/action_text/contents/_content.html.erb ที่ installer สร้าง (กรอบที่ CSS ข้างบนใช้)
  • บรรทัด 96 / 98 — comment BEGIN/END = ป้ายชื่อแม่พิมพ์ที่ Rails แปะให้ใน development (บท 120) · production ไม่มี
  • บรรทัด 97 — <strong> และ <a href> รอด · onclick ถูกตัด · แท็ก <script> ถูกถอด เหลือแค่ตัวหนังสือ alert(1) ที่ไม่รัน — ผลเดียวกับ sanitize ในบท 136
  • plain_text ไม่มี alert(1) เลย — to_plain_text ทิ้งเนื้อในของ <script> ด้วย

นี่คือ "sanitize ตอนโชว์" ของบท 136 ที่ Action Text ทำให้ทุกครั้งที่เขียน <%= post.body %> · ข้อเสียที่ต้องจำ: ของสกปรกยังอยู่ใน DB · ทางไหนที่ไม่ผ่าน <%= post.body %> (JSON · อีเมล) ต้องระวังเอง · แอปเรามีหน้า JSON ที่ scaffold สร้างไว้ (app/views/posts/_post.json.jbuilder — เปิด /posts/1.json ดูได้) ซึ่งส่ง description (ตัวหนังสือล้วน) จึงไม่มี HTML หลุดออกไป

🎂 เทียบให้เห็นภาพ — ข้อความบนหน้าเค้ก ตรวจตอนจัดจานทุกครั้ง

ลูกค้าเขียนข้อความบนหน้าเค้กเองได้ · ร้านมีรายการตัวตกแต่งที่อนุญาต (ตัวหนา · ลิงก์ · รายการ) ไม่อยู่ในรายการ = ไม่ใส่ · ร้านเก็บใบสั่งของลูกค้าไว้ตามที่เขียนมาทุกตัว แล้วตรวจตอนจัดจานทุกครั้ง · วันไหนร้านเพิ่มของต้องห้ามในรายการ ใบสั่งเก่าทุกใบก็โดนกฎใหม่ทันทีตอนเสิร์ฟครั้งถัดไป

ที่ไม่เหมือนกัน: ร้านจริงตรวจครั้งเดียวก็จำได้ · Rails ตรวจใหม่ทุกครั้งที่มีคนเปิดหน้า (เสียแรงซ้ำ — บล็อก F จะแก้ด้วย cache) · และคนจัดจานจริงไม่ปล่อยใบสั่งอันตรายออกทางอื่น แต่ใบสั่งใน DB ยังสกปรกอยู่ ใครหยิบออกไปทางอื่นโดยไม่ผ่านคนจัดจานก็ได้ของสกปรกไปทั้งก้อน

รายการแท็กที่อนุญาตอยู่ที่ไหน? สไลด์หน้า 6 ให้ดูที่ ActionText::ContentHelper.allowed_tags · ลองใน console — ผลจริง:

wongnok-blog-all-in-one(dev):001> ActionText::ContentHelper.allowed_tags
=> nil
wongnok-blog-all-in-one(dev):002> helper.sanitizer_allowed_tags.size
=> 46
wongnok-blog-all-in-one(dev):003> helper.sanitizer_allowed_tags.include?("script")
=> false
wongnok-blog-all-in-one(dev):004> helper.sanitizer_allowed_tags.include?("table")
=> false
wongnok-blog-all-in-one(dev):005> ActionText::ContentHelper.allowed_tags += ["table", "tr", "td"]
(wongnok-blog-all-in-one):5:in '<main>': undefined method '+' for nil (NoMethodError)

ถ้าวันหนึ่งต้องเพิ่มแท็กจริง ๆ ต้องเอารายการที่ใช้อยู่มาบวก แล้วกำหนดใหม่ทั้งก้อน — ลองใน console (ผลจริง · ตัดรายการยาวออก):

wongnok-blog-all-in-one(dev):006> ActionText::ContentHelper.allowed_tags = helper.sanitizer_allowed_tags + ["table", "tr", "td"]
=>
Set["a",
…
wongnok-blog-all-in-one(dev):007> helper.sanitizer_allowed_tags.size
=> 49
wongnok-blog-all-in-one(dev):008> helper.sanitizer_allowed_tags.include?("table")
=> true

(Set[…] = รายการแบบที่ไม่มีของซ้ำ ใช้แบบ Array ได้) · ค่าที่ตั้งใน console หายเมื่อปิด console · โปรเจกต์เราไม่เพิ่มอะไร (ไม่มีไฟล์ config/initializers/action_text.rb) — ตามบท 136: ขยาย allowlist เท่าที่จำเป็นเท่านั้น

⚠️ อย่าพิมพ์ตามสไลด์หน้า 6 (allowed_tags += [...]) — ใน Rails 8.1.4 ค่านี้เริ่มต้นเป็น nil แอปจะพังตั้งแต่เปิด · และอย่าเพิ่ม script · iframe · style ลงรายการเด็ดขาด

🗣 ศัพท์ที่เพิ่งเรียนไป
render-time sanitization (output sanitization)
ล้าง HTML ตอนส่งออกไปหน้าเว็บทุกครั้ง แทนการล้างตอนรับเข้า · กฎใหม่มีผลกับข้อมูลเก่าทันที · แต่ DB ยังเก็บของดิบ · ถาม senior ได้ว่า "rich text ของเราออกไปทางไหนบ้างที่ไม่ผ่าน view (JSON · อีเมล · export)"
allowed_tags · sanitizer_allowed_tags
ค่าที่เราตั้งเอง (เริ่มต้น nil) · กับรายการที่ใช้จริง (46 แท็ก) · พิมพ์บอก AI ได้ว่า "do not change ActionText allowed_tags unless I ask; if needed, start from sanitizer_allowed_tags instead of +=, because the default is nil"

ค้นหาจาก plain_text — ไม่ใช่จาก body

สไลด์หน้า 5 เตือนว่า "ค้นหาด้วย WHERE body LIKE ไม่ได้" · ลองให้เห็นกับตาใน console เดิมต่อจากข้างบน (บทความทดลองเลข 31 ยังอยู่) — ผลจริง (ตัด error ยาว ๆ ด้านล่างออก):

wongnok-blog-all-in-one(dev):009> Post.where("body ILIKE ?", "%อร่อยมาก%").count
  Post Count (0.6ms)  SELECT COUNT(*) FROM "posts" WHERE (body ILIKE '%อร่อยมาก%') /*application='WongnokBlogAllInOne'*/
(wongnok-blog-all-in-one):9:in '<main>': PG::UndefinedColumn: ERROR:  column "body" does not exist (ActiveRecord::StatementInvalid)
…
wongnok-blog-all-in-one(dev):010> Post.where("plain_text ILIKE ?", "%อร่อยมาก%").pluck(:id)
  Post Pluck (0.5ms)  SELECT "posts"."id" FROM "posts" WHERE (plain_text ILIKE '%อร่อยมาก%') /*application='WongnokBlogAllInOne'*/
=> [31]
wongnok-blog-all-in-one(dev):011> Post.where("plain_text ILIKE ?", "%strong%").pluck(:id)
  Post Pluck (0.4ms)  SELECT "posts"."id" FROM "posts" WHERE (plain_text ILIKE '%strong%') /*application='WongnokBlogAllInOne'*/
=> []

plain_text ถูกอัปเดตทุกครั้งที่บันทึกผ่าน model (callback ในขั้น 3) — ทั้งตอนสร้าง แก้ และตอน backfill · เสร็จแล้วลบบทความทดลอง: Post.find(31).destroy (ใช้เลขของคุณ) · log จะมี ActionText::RichText Destroy ตามด้วย Post Destroy = ลบแผ่นในลิ้นชักกลางให้ด้วย

ขั้น 6 · N+1 ตัวใหม่ — และ bullet ที่เตือนผิด

การ์ดหน้ารวมเรียก post.body.to_plain_text ทุกใบ · post.body แต่ละครั้ง = เดินไปลิ้นชักกลาง 1 รอบ (เห็นแล้วใน predict ขั้น 2) · เปิด localhost:3000/posts แล้วดู log ของใบสั่งนี้ — ผลจริง (ตัดบรรทัด Rendered ออก):

Processing by PostsController#index as HTML
  Post Count (9.0ms)  SELECT COUNT(*) FROM "posts" …
  Post Load (1.1ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT 10 OFFSET 0 …
  Category Load (0.9ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (5, 4, 3, 2, 1) …
  ActionText::RichText Load (1.0ms)  SELECT "action_text_rich_texts".* FROM "action_text_rich_texts" WHERE "action_text_rich_texts"."record_id" = 30 AND "action_text_rich_texts"."record_type" = 'Post' AND "action_text_rich_texts"."name" = 'body' LIMIT 1 …
  ↳ app/views/posts/_post.html.erb:24
  ActionText::RichText Load (1.1ms)  SELECT "action_text_rich_texts".* FROM "action_text_rich_texts" WHERE "action_text_rich_texts"."record_id" = 29 AND … LIMIT 1 …
  ↳ app/views/posts/_post.html.erb:24
  …  (record_id = 28 ลงไปถึง 21 อีก 8 ครั้ง)
Completed 200 OK in 96ms (Views: 47.2ms | ActiveRecord: 13.5ms (13 queries, 0 cached) | GC: 9.5ms)
…
USE eager loading detected
  Post => [:rich_text_body]
  Add to your query: .includes([:rich_text_body])

แก้ที่ controller (ไม่ใช่ view) — Action Text เตรียม scope ไว้ให้แล้วชื่อ with_rich_text_body (= with_rich_text_ + ชื่อช่อง) · app/controllers/posts_controller.rb action index:

  def index
    # with_rich_text_body = ดึง body ของทั้งหน้ามาในคำสั่งเดียว (ไม่งั้น N+1 ทีละบทความ)
    @pagy, @posts = pagy(Post.includes(:category).with_rich_text_body.recent, limit: 10)
  end

รีเฟรช — ผลจริง:

  ActionText::RichText Load (0.7ms)  SELECT "action_text_rich_texts".* FROM "action_text_rich_texts" WHERE "action_text_rich_texts"."record_type" = 'Post' AND "action_text_rich_texts"."name" = 'body' AND "action_text_rich_texts"."record_id" IN (30, 29, 28, 27, 26, 25, 24, 23, 22, 21) …
Completed 200 OK in 22ms (Views: 13.7ms | ActiveRecord: 4.3ms (4 queries, 0 cached) | GC: 0.0ms)
…
AVOID eager loading detected
  Post => ["rich_text_body"]
  Remove from your query: .includes([:rich_text_body])

ทำไมเตือนผิด: ในโค้ดของ Action Text scope นี้เขียนชื่อเป็นข้อความ "rich_text_body" · แต่ bullet นับการใช้งานเป็นสัญลักษณ์ :rich_text_body (บท 92) · สองอย่างนี้หน้าตาคล้ายกันแต่ไม่นับเป็นตัวเดียวกัน — สังเกตใน log: ["rich_text_body"] มีเครื่องหมายคำพูด

📋 เทียบให้เห็นภาพ — ผู้ตรวจที่เทียบลายมือกับป้ายพิมพ์

เราเขียนใบเบิก "แผ่นเนื้อหาของบทความทั้ง 10 ชิ้น" ไปทีเดียว (แก้ N+1 แล้ว) · ผู้ตรวจ (bullet) เอาใบเบิกที่เขียนด้วยลายมือไปเทียบกับรายการของที่ใช้ซึ่งพิมพ์ด้วยเครื่อง · ตัวอักษรเหมือนกันทุกตัว แต่ผู้ตรวจถือว่าคนละรายการ จึงสรุปว่า "เบิกมาแล้วไม่ได้ใช้"

ที่ไม่เหมือนกัน: ผู้ตรวจจริงเห็นของวางอยู่บนโต๊ะก็รู้ว่าใช้แล้ว · bullet ไม่ได้ดูผลลัพธ์ มันดูแค่ชื่อ · เราจึงต้องเชื่อตัวเลข (N queries) มากกว่าคำเตือน แล้วบอก bullet ว่า "เรื่องนี้ไม่ต้องเตือน"

บอก bullet ให้เงียบเฉพาะเรื่องนี้ — config/environments/development.rb ในก้อน config.after_initialize do ใต้ Bullet.add_footer = true:

    # with_rich_text_body ใช้ชื่อ "rich_text_body" แบบ string แต่ Bullet นับการใช้งานเป็น symbol
    # จึงเตือน AVOID eager loading ทั้งที่ใช้จริง (เตือนผิด) — ห้ามลบ with_rich_text_body ตามคำเตือนนี้
    Bullet.add_safelist type: :unused_eager_loading, class_name: "Post", association: :rich_text_body

⚠️ แก้ไฟล์ใน config/environments/ แล้วต้องปิด-เปิด bin/dev ใหม่ — ผมลองรีเฟรชโดยไม่ restart: AVOID ยังขึ้น · restart แล้ว: ไม่มีคำเตือน และยัง 4 queries

🗣 ศัพท์ที่เพิ่งเรียนไป
with_rich_text_body (eager loading ของ rich text)
scope ที่ Action Text สร้างให้ = includes(:rich_text_body) · มีอีกตัว with_rich_text_body_and_embeds สำหรับตอนโชว์ไฟล์แนบด้วย · พิมพ์บอก AI ได้ว่า "any list page that reads post.body must use Post.with_rich_text_body in the controller"
false positive
เครื่องมือเตือนว่ามีปัญหาทั้งที่ไม่มี · แก้ด้วยการยกเว้นเฉพาะจุด + เขียนเหตุผลไว้ ไม่ใช่ปิดเครื่องมือทั้งตัว · ถาม senior ได้ว่า "เราเก็บรายการยกเว้นของ linter/bullet ไว้ที่ไหน และใครทบทวน"

ปิดงาน — seeds แล้ว commit

เหลือไฟล์สุดท้าย: db/seeds.rb (ข้อมูลตั้งต้นที่ bin/rails db:seed ใส่ให้ — Day 3) ยังสร้างบทความด้วย description · ถ้าเพื่อนสร้าง DB ใหม่จาก seeds จะได้บทความที่ไม่มี body · แก้ในก้อน Post.find_or_create_by! ท้ายไฟล์:

    title, text = POSTS[slug][round]
    Post.find_or_create_by!(title: title) do |post|
      # เนื้อหาอยู่ใน body (Action Text) แล้ว · description กับ plain_text ถูกเติมให้เองตอน save (ดู app/models/post.rb)
      post.body        = text
      post.category    = categories[slug]
      post.created_at  = (30 - position).days.ago
    end

ดูว่าแตะอะไรไปบ้าง แล้ว commit — ผลจริง:

$ git add -A
$ git status --short
A  app/assets/tailwind/actiontext.css
M  app/assets/tailwind/application.css
M  app/controllers/posts_controller.rb
M  app/javascript/application.js
M  app/models/post.rb
A  app/views/active_storage/blobs/_blob.html.erb
A  app/views/layouts/action_text/contents/_content.html.erb
M  app/views/posts/_form.html.erb
M  app/views/posts/_post.html.erb
M  config/environments/development.rb
M  config/importmap.rb
A  db/migrate/20261110034320_create_active_storage_tables.active_storage.rb
A  db/migrate/20261110034321_create_action_text_tables.action_text.rb
A  db/migrate/20261110034341_add_plain_text_to_posts.rb
M  db/schema.rb
M  db/seeds.rb
A  lib/tasks/posts.rake
A  test/fixtures/action_text/rich_texts.yml
$ git commit -m "Action Text: เนื้อหาเป็น rich text (body) + ย้ายข้อมูลจาก description แบบ expand"
[my-day3 381409b] Action Text: เนื้อหาเป็น rich text (body) + ย้ายข้อมูลจาก description แบบ expand
 18 files changed, 651 insertions(+), 10 deletions(-)
 create mode 100644 app/assets/tailwind/actiontext.css
 create mode 100644 app/views/active_storage/blobs/_blob.html.erb
 create mode 100644 app/views/layouts/action_text/contents/_content.html.erb
 create mode 100644 db/migrate/20261110034320_create_active_storage_tables.active_storage.rb
 create mode 100644 db/migrate/20261110034321_create_action_text_tables.action_text.rb
 create mode 100644 db/migrate/20261110034341_add_plain_text_to_posts.rb
 create mode 100644 lib/tasks/posts.rake
 create mode 100644 test/fixtures/action_text/rich_texts.yml

ให้ AI ช่วย — แต่ตรวจด้วย checklist นี้

งานแบบนี้ (เปลี่ยนช่องข้อความเป็น rich text บน model อื่น เช่นช่อง bio ของผู้ใช้) ให้ AI ทำได้เมื่อคุณทำเองเป็นแล้ว · ตัวอย่าง prompt (บท 141 จะเขียนฉบับเต็มเก็บใน prompts/04-content.md):

อ่าน prompts/00-context.md ก่อน
เปลี่ยน Shop#description (ข้อความล้วน) เป็น rich text ด้วย Action Text (has_rich_text :body)
- ห้ามลบคอลัมน์ description ในรอบนี้ (expand-contract) · ให้ model เขียนลง description ด้วยทุกครั้งที่ save
- เพิ่มคอลัมน์ plain_text ที่เติมจาก body.to_plain_text ตอน before_save
- ย้ายข้อมูลเดิมด้วย rake task ที่รันซ้ำได้ (ห้ามทำใน migration) · escape ข้อความเดิมก่อนห่อเป็น HTML
- หน้ารายการที่อ่าน body ต้องใช้ with_rich_text_body
- ห้ามใช้ raw หรือ html_safe กับ body · ห้ามแก้ allowed_tags
บอกรายการไฟล์ที่จะแก้ก่อนลงมือ

(Shop เป็นชื่อสมมติ — แอปเราไม่มี model นี้ · ใช้เป็นแบบเขียน prompt เท่านั้น) · ตรวจงานที่ AI ส่งกลับมาด้วยข้อพวกนี้:

  1. git diff ต้องไม่มี remove_column :…, :description และไม่มี migration ที่เรียก model ข้างใน
  2. รัน rake task สองรอบ · รอบสองต้องไม่สร้างแถวซ้ำ (ActionText::RichText.count เท่าเดิม)
  3. grep -rn "raw\|html_safe" app/views (-r = ค้นทุกไฟล์ในโฟลเดอร์ · \| = "หรือ") ต้องไม่เจอที่ใช้กับ body
  4. เปิดหน้ารายการ ดู (N queries) ใน log · ถ้า AI ลบ with_rich_text_body ตามคำเตือน AVOID ของ bullet → ตัวเลขจะพุ่งกลับ
  5. ลองบันทึกเนื้อหาที่มี <script>alert(1)</script> ใน console แล้ว curl หน้า show ต้องไม่เห็นแท็ก script

🎯 ภารกิจ — เขียนบทความผ่านหน้าเว็บ แล้วตามไปดูว่าแต่ละส่วนไปอยู่ไหน (~10 นาที)

ใช้โค้ดที่ commit แล้ว · bin/dev เปิดอยู่

  1. เปิด localhost:3000/posts/new · หัวข้อ ร้านลับย่านบางรัก · หมวดอะไรก็ได้
  2. ในช่องเนื้อหา: กดปุ่ม B พิมพ์ เมนูเด็ด กด B อีกครั้งให้ปิด · Enter · กดปุ่มรายการแบบจุด (ไอคอนสามขีดมีจุด) พิมพ์ ต้มยำ · Enter · พิมพ์ แกงเขียวหวาน · กด "บันทึก"
  3. เปิด bin/rails console แล้วพิมพ์ทีละบรรทัด:
    post = Post.last
    ActionText::RichText.last.body.to_html
    post.plain_text
    post.destroy
    ActionText::RichText.count

ก่อนกดเฉลย ทายสามข้อ: ① ลิ้นชักกลางเก็บแท็กอะไรบ้าง ② plain_text หน้าตาเป็นยังไง (รายการแบบจุดจะเหลืออะไร) ③ ลบบทความแล้ว RichText.count เหลือเท่าไร

ผลจริง (ตัดบรรทัด SQL บางส่วน):

wongnok-blog-all-in-one(dev):003> ActionText::RichText.last.body.to_html
=> "<div><strong>เมนูเด็ด</strong></div><ul><li>ต้มยำ</li><li>แกงเขียวหวาน</li></ul>"
wongnok-blog-all-in-one(dev):004> post.plain_text
=> "เมนูเด็ด\n• ต้มยำ\n• แกงเขียวหวาน"
wongnok-blog-all-in-one(dev):005> post.destroy
  …
  ActionText::RichText Destroy (0.6ms)  DELETE FROM "action_text_rich_texts" WHERE "action_text_rich_texts"."id" = 33 …
  Post Destroy (0.7ms)  DELETE FROM "posts" WHERE "posts"."id" = 33 …
  …
wongnok-blog-all-in-one(dev):006> ActionText::RichText.count
=> 30
  • ① <div> <strong> <ul> <li> — ทั้งหมดอยู่ในรายการ 46 แท็กที่อนุญาต จึงโชว์ครบ · หน้าบทความเห็นจุดหน้ารายการเพราะบรรทัด .trix-content ul { list-style: disc; }
  • ② ตัวหนาหายเหลือแต่คำ · รายการกลายเป็น • นำหน้า · คั่นด้วย \n (ขึ้นบรรทัดใหม่) — พร้อมค้นหา
  • ③ กลับเป็น 30 — RichText Destroy มาก่อน Post Destroy = Rails ลบแผ่นในลิ้นชักกลางให้เอง (dependent: :destroy ที่ has_rich_text ตั้งไว้) · เลข id ของคุณต่างจากนี้ได้

ถ้าเจออาการนี้ แปลว่า…

  • พิมพ์เนื้อหาแล้ว แต่ขึ้น Body can't be blank — params.expect ใน controller ยังเป็น :description (ลองแล้วได้อาการนี้จริง) · เปลี่ยนเป็น :body
  • หน้า /posts การ์ดทั้ง 10 ใบไม่มีข้อความย่อ — ยังไม่ได้รัน bin/rails posts:backfill_body (ผมลองลบ body ทั้งหมดแล้วเปิดหน้า ได้การ์ดว่าง 10 ใบจริง) · รันแล้วรีเฟรช
  • ใส่ safelist แล้ว bullet ยังขึ้น AVOID — ยังไม่ได้ปิด-เปิด bin/dev ใหม่

ทดสอบความเข้าใจ

หลังติดตั้ง Action Text แล้ว เพื่อนเขียน Post.where("body ILIKE ?", "%ต้มยำ%") เพื่อค้นบทความ · ผลคืออะไร และควรค้นแบบไหน?

ตัวเลือกที่ 2 ถูก — ผลจริง: PG::UndefinedColumn: ERROR: column "body" does not exist · เนื้อหาอยู่ในตาราง action_text_rich_texts (ลิ้นชักกลาง) · ค้นจาก plain_text ได้ผลและไม่มีชื่อแท็กปน

ตัวเลือกที่ 1 — has_rich_text ไม่ได้เพิ่มคอลัมน์ในตาราง posts (เราไม่ได้เขียน migration เพิ่ม body เลย) · ตัวเลือกที่ 3 — ไม่ถึงขั้นได้ผลว่าง มัน error ตั้งแต่ไม่มีคอลัมน์ และค้นจาก HTML ก็เจอชื่อแท็กปน (บท 136) · ตัวเลือกที่ 4 — with_rich_text_body ช่วยเรื่องโหลดมาโชว์ ไม่ได้ทำให้ WHERE body ใช้ได้

จุดที่ต้องจำ: rich text อยู่อีกตาราง · ค้นหา = plain_text · โชว์ = post.body

AI เสนอว่า "ให้ sanitize body ตอน save แล้วเลิก sanitize ตอนแสดง หน้าเว็บจะเร็วขึ้น" · ข้อไหนตอบได้ดีที่สุด?

ตัวเลือกที่ 4 ถูก — ตรวจตอนโชว์ = ใบสั่งเก่าทุกใบโดนกฎใหม่ทันทีตอนเสิร์ฟครั้งถัดไป (สไลด์หน้า 6) · Action Text ทำแบบนี้ให้อยู่แล้ว (เห็นในหัวข้อ "ล้างของอันตรายให้ตอนโชว์": DB มี <script> แต่หน้าเว็บไม่มี)

ตัวเลือกที่ 1 — Action Text sanitize ตอนแสดงอยู่แล้ว (onclick ถูกตัดใน curl) · ตัวเลือกที่ 2 — "ไม่มีทางมีปัญหาเพิ่ม" ผิด เพราะช่องโหว่แบบใหม่ถูกค้นพบเรื่อย ๆ · ตรวจครั้งเดียวด้วยกฎเก่าไม่พอ · ตัวเลือกที่ 3 — sanitize ไม่ได้ลบตัวหนาหรือลิงก์ มันเก็บแท็กในรายการไว้ (<strong> <a href> รอด)

จุดที่ต้องจำ: เก็บดิบ ล้างตอนโชว์ทุกครั้ง · ห้ามใส่ raw/html_safe ครอบ post.body

หน้ารวมใช้ with_rich_text_body แล้ว log ขึ้น (4 queries) แต่ bullet ขึ้น AVOID eager loading … Remove from your query: .includes([:rich_text_body]) · ควรทำอย่างไร?

ตัวเลือกที่ 1 ถูก — ตัวเลขจริง: ไม่มี with_rich_text_body = 13 queries · มี = 4 · คำเตือนผิดเพราะ scope ใช้ชื่อแบบข้อความ "rich_text_body" แต่ bullet นับแบบสัญลักษณ์ · safelist เฉพาะจุด + comment กันคนรุ่นหลังลบ

ตัวเลือกที่ 2 — ทำตามแล้ว N+1 กลับมาทันที (13 queries) · bullet ไม่ได้ "เห็น" การใช้งานจริง มันเทียบแค่ชื่อ · ตัวเลือกที่ 3 — เสียผู้ช่วยที่จับ N+1 ตัวอื่นได้ไปทั้งตัว ทั้งที่ผิดแค่จุดเดียว · ตัวเลือกที่ 4 — การหยิบล่วงหน้าต้องอยู่ที่ใบเบิกใน controller (บท 125) · view ไม่ใช่ที่ของมัน

จุดที่ต้องจำ: เชื่อ (N queries) ใน log มากกว่าคำเตือน · ยกเว้นเฉพาะจุดพร้อมเหตุผล ไม่ปิดทั้งเครื่องมือ

ย้ายเนื้อหาไป body ครบแล้ว ทำไมรอบนี้ model ยังเขียนลง description ด้วยทุกครั้งที่บันทึก แทนที่จะลบคอลัมน์ description ทิ้งเลย?

ตัวเลือกที่ 3 ถูก — นี่คือเฟส expand ของบท 109: ของใหม่มาแล้ว ของเก่ายังอยู่และถูกเขียนคู่กัน · ถ้า deploy แล้วต้องถอยโค้ดกลับ บทความใหม่ก็ยังมี description ให้โค้ดเก่าอ่าน · อีกเหตุผลคือคอลัมน์ตั้ง null: false · ลบจริงในรอบ contract

ตัวเลือกที่ 1 — Action Text อ่านจากลิ้นชักกลางล้วน ๆ (predict ขั้น 2: description เต็มแต่ post.body ว่าง) · ตัวเลือกที่ 2 — Rails ไม่ได้ห้ามลบคอลัมน์ที่มีข้อมูล (มีแค่ strong_migrations ที่โปรเจกต์ติดตั้งไว้คอยเตือน — บท 109) และไม่มีกฎให้เขียนค่าว่างก่อนลบ · ตัวเลือกที่ 4 — หน้ารวมของเราไม่ได้ใช้ description แล้ว มันตัดคำโปรยจาก post.body.to_plain_text

จุดที่ต้องจำ: expand = เพิ่มใหม่ + ย้าย + เขียนคู่ · ลบของเก่าเป็นอีกรอบ (contract) หลังมั่นใจแล้ว

✅ ก่อนไปบทถัดไป: git status --short ต้องว่าง (commit แล้ว) · ลบบทความทดลองใน console แล้ว บทความต้องเหลือ 30 ชิ้น (Post.count) และ ActionText::RichText.count = 30 · บท 138 จะเก็บรูปปกของบทความ — ตาราง Active Storage ที่สร้างไปแล้วในบทนี้จะได้ใช้

ถ้าเปิดสไลด์ดูประกอบ

หน้าสไลด์เขียนของจริง (Rails 8.1.4 · แอปของคอร์ส)ต้องสนใจไหม
5action_text:install "สร้างตาราง + ใส่ JS ให้"สร้างตาราง Active Storage 3 ตารางด้วย และสร้าง actiontext.css ที่ต้องย้ายเข้า Tailwind (ไม่งั้นคลาส Tailwind ไม่มีผลกับ editor · ลิงก์ไม่มีสี)สนใจ — ทำตามขั้น 5
5"polymorphic association — เราคุยคำนี้กันวันพฤหัส"เว็บนี้ยังไม่เคยสอน · อธิบายครั้งแรกในบทนี้ (ลิ้นชักกลาง)ไม่ต้อง
5"แก้ด้วย Post.with_rich_text_body"ถูก (13 → 4 queries) · แต่ bullet เตือน AVOID ผิด ต้อง safelistสนใจ — อย่าลบตามคำเตือน
6ActionText::ContentHelper.allowed_tags += [...]ค่าเริ่มต้นเป็น nil → undefined method '+' for nil · ถ้าจำเป็นต้องเริ่มจาก helper.sanitizer_allowed_tagsสนใจ ถ้าพิมพ์ตาม
6"Lexxy เป็นตัวใหม่ของ 8.1"ไม่ได้มากับ Rails 8.1.4 (ไม่มีใน Gemfile.lock และโค้ดของ Action Text) · เป็น gem แยก · editor ที่มากับ Rails คือ Trixไม่ต้อง
14"ห้ามลบคอลัมน์ description ในรอบนี้"โค้ดของคอร์สทำเพิ่มอีกขั้น: model เขียนลง description ทุกครั้งที่บันทึก (เขียนคู่) เพราะคอลัมน์ห้ามว่าง และให้ถอยโค้ดกลับได้ไม่ต้อง — ทำตามบทนี้ก็ครบ

อ้างอิง

ติดตรงไหน หรือผลของคุณต่างจากในบท — ก็อปผลจริงทั้งก้อนไปถาม AI หรือเพื่อนได้เลย โดยบอกว่ามาจากบท 137 ขั้นไหน