🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้
- allowlist · sanitize ตอนโชว์ (บท 136) — เก็บเฉพาะแท็กที่อยู่ในรายการที่อนุญาต ถอดที่เหลือ · ทำตอนแสดงผลทุกครั้ง ไม่ใช่ตอนบันทึก · ช่อง plain text ไว้ค้นหาโดยไม่มีชื่อแท็กปน
- expand-contract (บท 109) — เปลี่ยนโครงตารางทีละเฟส: ทำของใหม่ก่อน → ย้ายข้อมูล (backfill) ในงานแยก → ค่อยลบของเก่ารอบหลัง
before_save·private(บท 112) — สั่งให้ทำอะไรก่อนบันทึกทุกครั้ง ทั้งตอนสร้างและตอนแก้- N+1 ·
includes· bullet (บท 114 · บท 125) — เดินไปตู้ทีละแผ่น vs ใบเบิกทีเดียวทั้งตะกร้า · ดู(N queries)ท้ายบรรทัดCompletedใน log params.expect(บท 117) — รายชื่อช่องในฟอร์มที่ controller ยอมรับ
🧰 เวอร์ชันที่ใช้ตรวจ: 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 text | app/models/post.rb |
| 3 | เพิ่มคอลัมน์ plain_text + ให้ model เขียนทั้งช่องใหม่และช่องเก่า (expand) | migration · post.rb |
| 4 | ย้ายเนื้อหา 30 บทความเดิมจาก description ไป body | lib/tasks/posts.rake |
| 5 | เปลี่ยนฟอร์มและหน้าแสดงผลไปใช้ body + จัด CSS ของ Trix | _form · _post · controller · CSS |
| 6 | กัน N+1 ในหน้ารวม + แก้ seeds + commit | controller · 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 = สร้างไฟล์ใหม่):
Installing JavaScript dependencies— แค่หัวข้อ ไม่ได้ดาวน์โหลดอะไร · installer จะเรียก yarn/bun (ตัวติดตั้ง package ของ JavaScript แบบเดียวกับ npm) เฉพาะโปรเจกต์ที่มีpackage.json· โปรเจกต์เราใช้ importmap (บท 127) ไม่มีไฟล์นี้ จึงข้ามไปapplication.js+importmap.rb— เพิ่มบรรทัดโหลด Trix กับตัวเชื่อม Trix ↔ Rails · ทั้งคู่ชี้ไฟล์ที่อยู่ใน gem ของ Rails ไม่ใช่ CDN (ดูข้างล่าง)actiontext.css— หน้าตาของแถบปุ่มและเนื้อหา (440 บรรทัด) · เดี๋ยวขั้น 5 ต้องย้ายที่ ไม่งั้นชนกับ Tailwind_blob.html.erb— แม่พิมพ์ของรูป/ไฟล์ที่แนบในเนื้อหา (เรื่องไฟล์เริ่มบท 138) ·_content.html.erb— กรอบ<div class="trix-content">ที่ห่อเนื้อหาทุกครั้งที่โชว์railties:install:migrations— คำสั่งที่ installer เรียกต่อเอง: ก็อปไฟล์ migration ที่มากับ gem มาใส่db/migrate/ของเรา (ยังไม่สร้างตาราง) · ได้มา 2 ไฟล์ · ไฟล์แรกเป็นของ Active Storage (ระบบเก็บไฟล์ของ Rails — บท 138) เพราะรูปที่แนบในเนื้อหาต้องเก็บด้วยระบบนั้นrich_texts.yml— ไฟล์ข้อมูลตัวอย่างสำหรับ test (มีแต่ comment ไม่มีผลอะไร · เรื่อง test อยู่ Part 2)
สองไฟล์ที่ถูก 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"
+หน้าบรรทัด = บรรทัดที่เพิ่ม ·import "trix"= โหลดโปรแกรม editor ·import "@rails/actiontext"= ตัวเชื่อมที่ทำให้ลากรูปใส่เนื้อหาแล้วอัปโหลดได้ (บท 139)pin "trix"— บอก importmap ว่าชื่อtrixหมายถึงไฟล์trix.jsที่อยู่ใน gemaction_text-trix(ติดมากับ Rails แล้ว) · ไม่มีการโหลดจากเว็บอื่น
แล้วสร้างตาราง — ผลจริง:
$ 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) ==================
- สามตารางแรก (
active_storage_…) — ของ Active Storage · ยังไม่ได้ใช้ในบทนี้ · จำไว้ว่า "มีแล้ว" เพราะบท 138 จะไม่ต้องสร้างซ้ำ action_text_rich_texts— ตารางที่เก็บเนื้อหา rich text · หัวข้อถัดไปดูข้างใน
หลังติดตั้งแล้ว ปิด 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
body— ตัว HTML ของเนื้อหา (ชื่อคอลัมน์บังเอิญตรงกับชื่อช่องที่เราจะตั้ง แต่คนละที่กัน)name— ชื่อช่อง เช่น"body"· เผื่อ model หนึ่งมี rich text หลายช่อง (เช่น เนื้อหา + บทสรุป)record_type+record_id— เนื้อหานี้เป็นของแถวไหน ในตารางไหน เช่น"Post"+5= บทความเลข 5index … unique: true— กันไม่ให้แถวเดียวกันมีช่องชื่อเดียวกันซ้ำสองอัน (index ไม่ซ้ำ — บท 111)
ต่างจากที่เคยเห็นตรงไหน? ตาราง 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
…
has_rich_text :body— "บทความมีช่อง rich text ชื่อbodyเก็บในลิ้นชักกลาง" · ได้post.bodyมาใช้เหมือนคอลัมน์ธรรมดา · เบื้องหลังมันคือhas_one(บท 115) ชื่อrich_text_bodyที่ตั้งdependent: :destroyไว้ให้ = ลบบทความแล้วเนื้อหาหายตาม- ไม่ต้องเขียน migration เพิ่มคอลัมน์
bodyในตาราง posts — ตารางกลางมีอยู่แล้ว
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 — เพิ่มของใหม่ก่อน ย้ายข้อมูล แล้วปล่อยของเก่าไว้ ค่อยลบรอบหน้า
ร้านเปลี่ยนมาเขียนเนื้อหาบนกระดาษในลิ้นชักกลาง · แต่ช่อง "เนื้อหา" บนแฟ้มเดิมยังอยู่ และสั่งพนักงานว่า "ช่วงนี้เขียนทั้งสองที่" · ถ้าระบบใหม่มีปัญหาแล้วต้องถอยกลับไปใช้คู่มือเก่า แฟ้มทุกแผ่นยังมีข้อมูลครบ · พอมั่นใจแล้วค่อยประกาศเลิกใช้ช่องเดิม แล้วลบรอบหน้า
ที่ไม่เหมือนกัน: พนักงานจริงลืมเขียนที่สองได้ · ในโค้ด "เขียนสองที่" คือ 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
validates :body, presence: true— แทนvalidates :descriptionเดิม · ตอนนี้ช่องที่ผู้ใช้ต้องกรอกคือ bodybefore_save :copy_body_to_plain_text— ก่อนบันทึกทุกครั้ง เรียก method ข้างล่าง (บท 112)body.to_plain_text— ถอดแท็กทิ้ง เหลือตัวหนังสือล้วน (แบบstrip_tagsในบท 136 · ขึ้นบรรทัดใหม่ยังเป็น\n) → เก็บลงplain_textself.description = plain_text— เขียนลงช่องเก่าด้วย (= "เขียนทั้งสองที่") · เหตุผลที่สองคือคอลัมน์descriptionในฐานข้อมูลตั้งไว้ว่าห้ามว่าง (null: false) · ถ้าไม่เติมให้ บทความใหม่จะบันทึกไม่ได้
- 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
namespace :posts+task backfill_body:— ได้ชื่อคำสั่งposts:backfill_body·desc= คำอธิบายที่โชว์ในbin/rails -T·:environment= โหลดแอปก่อน (ไม่งั้นใช้Postไม่ได้)where.missing(:rich_text_body)— "บทความที่ยังไม่มีแผ่นในลิ้นชักกลาง" (rich_text_bodyคือชื่อhas_oneที่has_rich_text :bodyสร้างให้) · ทำให้รันซ้ำได้: รอบสองไม่เหลือใครให้ย้ายfind_each— วนทีละกองแทนโหลดทั้งตารางเข้าหน่วยความจำ (บท 114)ERB::Util.html_escape— ใส่ซองใสแบบเดียวกับ<%= %>· description เดิมเป็นตัวหนังสือล้วน ถ้าในนั้นมีคำว่า<b>ต้องโชว์เป็นตัวอักษร ไม่ใช่กลายเป็นตัวหนาหลังย้ายgsub(/\r?\n/, "<br>")— เปลี่ยนการขึ้นบรรทัดใหม่เป็นแท็ก<br>(/…/= รูปแบบที่จะค้น ·\r?\n= ขึ้นบรรทัดใหม่ทั้งแบบ Windows และ Mac)update!— บันทึกผ่าน model →before_saveทำงาน →plain_textถูกเติมไปพร้อมกัน ·!= พังให้เห็นถ้าบันทึกไม่ได้ (บท 107)
เทียบกับของที่รู้: 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
LEFT OUTER JOIN … WHERE "rich_text_body"."id" IS NULL— SQL ของwhere.missing: "เอาบทความทุกชิ้นไปจับคู่กับแผ่นในลิ้นชักกลาง แล้วเลือกเฉพาะชิ้นที่หาคู่ไม่เจอ"30ทั้งสองบรรทัด — ยังไม่มีบทความไหนมี body หรือ plain_text เลย
ออกจาก 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">
0·0·30— ทุกบทความมี body และ plain_text แล้ว · ลิ้นชักกลางมี 30 แผ่นพอดี (ไม่ซ้ำแม้รันสองรอบ)- แผ่นแรก:
record_type: "Post"+record_id: 1= เนื้อหาของบทความเลข 1 ·bodyขึ้นต้นด้วย<div>ต้มยำกุ้ง…ตามที่ task ห่อไว้ · เวลาcreated_atและเลข0x…ของคุณจะต่างจากนี้ descriptionยังอยู่ครบทุกแถว — ไม่มีคำสั่งไหนในบทนี้ลบมัน
📝 ผมลองใส่บทความทดสอบที่ description เป็น บรรทัดแรก + ขึ้นบรรทัดใหม่ + <b>หนา</b> & จบ แล้วรันงานนี้ · body ที่ได้คือ
<div>บรรทัดแรก<br><b>หนา</b> & จบ</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>
- เดิม:
form.label :description …+form.textarea :description, rows: 4, class: ["textarea w-full mt-2", …]· ใหม่: เปลี่ยนเป็น:bodyและrich_textarea rich_textarea— ชื่อจริงใน Rails 8.1 (ชื่อเก่าrich_text_areaยังใช้ได้ · ตรวจในโค้ดของ Rails แล้ว) · วาดช่อง Trix แทนกล่องข้อความธรรมดาmin-h-40= สูงอย่างน้อย 160px ·{"border-error": …}= กรอบแดงเมื่อช่องนี้มี error (แบบเดียวกับช่องอื่นใน Day 3) · ไม่ใส่คลาสtextareaของ daisyUI เพราะ Trix มีกรอบของตัวเอง
② 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>
- จุดแรก (หน้าเต็ม
full) — เดิม<p class="leading-relaxed whitespace-pre-line"><%= post.description %></p>· ใหม่เป็น<div>เพราะเนื้อหามี<div><ul>ข้างใน (ใส่ใน<p>ไม่ได้) ·whitespace-pre-lineไม่ต้องแล้ว เพราะขึ้นบรรทัดใหม่เป็น<br> <%= post.body %>— ไม่ต้องใส่sanitizeหรือraw· Action Text sanitize ให้ตอนวาดเอง (หัวข้อถัดไปพิสูจน์)- จุดที่สอง (การ์ดหน้ารวม) — เดิม
truncate(post.description, …)· ใหม่ตัดคำโปรยจากpost.body.to_plain_text(ตัวหนังสือล้วน ไม่มีแท็กปน) · ผลของto_plain_textไม่ได้ผ่านการล้าง (เอกสาร Rails เตือนว่าไม่รับประกันว่าปลอดภัยถ้าเอาไปใส่เป็น HTML) แต่<%= %>ใส่ซองใสให้ จึงปลอดภัย — ห้ามครอบด้วยraw
เปิด 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>
curl -s= ขอหน้าเว็บแบบไม่โชว์แถบความคืบหน้า ·| grep -n 'trix-editor'= เก็บเฉพาะบรรทัดที่มีคำนี้ พร้อมเลขบรรทัด (85:— หลังย้าย CSS ในหัวข้อถัดไปจะเหลือ84:เพราะ<link>ของactiontext.cssหายไปหนึ่งบรรทัด)<input type="hidden" name="post[body]">— ช่องที่ถูกส่งไปจริง แต่ซ่อนไว้ · ทุกครั้งที่พิมพ์ Trix เขียน HTML ลงช่องนี้<trix-editor … input="post_body_trix_input_post">— ช่องที่ผู้ใช้เห็นและพิมพ์ ·input="…"บอกว่าให้เขียนผลลงช่องซ่อนตัวไหนdata-direct-upload-url— ที่อยู่สำหรับอัปโหลดไฟล์ที่แนบในเนื้อหา · เรื่องของบท 139
ผมพิมพ์ "ร้านนี้ อร่อยมาก แนะนำ" (กดปุ่ม B ตรงคำกลาง) ใน Chrome จริง · ช่องซ่อนได้ค่า <div>ร้านนี้ <strong>อร่อยมาก</strong> แนะนำ</div> ·
กดบันทึกแล้วหน้าบทความโชว์ตัวหนาจริง · ( = ช่องว่างแบบที่ 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; }
- บรรทัดที่เพิ่ม: 3 บรรทัดหลัง
@import "tailwindcss";และทุกอย่างหลังก้อน@theme· ที่เหลือเป็นของเดิมจาก Day 3 @import "./actiontext.css" layer(components);— โหลดไฟล์ของ Trix เข้าชั้นที่คลาส Tailwind ทับได้ · ไม่ต้องแก้เนื้อในไฟล์ 440 บรรทัดนั้นเลย.trix-content …— คืนตัวหนาให้หัวข้อ จุดหน้ารายการ และสี+ขีดเส้นใต้ให้ลิงก์ เฉพาะในกรอบเนื้อหา (กรอบที่_content.html.erbห่อให้) · ส่วนอื่นของเว็บไม่โดนtrix-toolbar …— ไอคอนบนแถบปุ่มเป็นรูปสีดำ · ธีมมืดของ Day 3 จะมองไม่เห็น จึงให้พื้นขาวเสมอ
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)
allowed_tags→nil— "ยังไม่มีใครตั้งค่าเอง" · ไม่ได้แปลว่าไม่มีรายการhelper.— ใน console ใช้helperเรียก method ที่ view ใช้ได้ (บท 104) ·.size= นับจำนวน ·.include?("script")= "ในรายการมีคำนี้ไหม"helper.sanitizer_allowed_tags— method ใน Action Text ที่คืนรายการที่ใช้จริง: ถ้าallowed_tagsเป็น nil ก็ใช้รายการเริ่มต้นของ sanitize (บท 125) + แท็กสำหรับไฟล์แนบ 3 ตัว · รวม 46 แท็ก · ไม่มีscript· ไม่มีtable- บรรทัด 5 — โค้ดของสไลด์
allowed_tags += [...]= "เอาค่าเดิม บวก ของใหม่" แต่ค่าเดิมคือ nil บวกไม่ได้ → พัง
ถ้าวันหนึ่งต้องเพิ่มแท็กจริง ๆ ต้องเอารายการที่ใช้อยู่มาบวก แล้วกำหนดใหม่ทั้งก้อน — ลองใน 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'*/
=> []
ILIKE= ค้นคำแบบไม่สนตัวพิมพ์เล็ก/ใหญ่ (ของ PostgreSQL — บท 136) ·%…%= "มีคำนี้อยู่ตรงไหนก็ได้" ·?= ช่องที่ Rails เอาค่าถัดไปมาใส่ให้อย่างปลอดภัย- บรรทัด 9 —
column "body" does not exist· ตาราง posts ไม่มีคอลัมน์ body (body ของเราอยู่ในลิ้นชักกลาง) - บรรทัด 10 — ค้นจาก
plain_textเจอบทความทดลอง (เลข 31 · ของคุณจะเป็นเลขเดียวกับpost.idตอนสร้าง) - บรรทัด 11 — ค้นคำว่า strong ไม่เจอ ทั้งที่ HTML ของบทความนั้นมี
<strong>· บท 136 ค้นจาก HTML แล้วเจอเพราะชื่อแท็กปน · plain_text แก้ปัญหานั้นแล้ว
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])
GC:ท้ายบรรทัดCompleted= เวลาที่ Ruby เก็บกวาดหน่วยความจำ ไม่ต้องสนใจActionText::RichText Load … record_id = 30·= 29· … — บรรทัดเดียวกันซ้ำ 10 ครั้ง ต่างแค่เลข = N+1 (บท 114)_post.html.erb:24ลบหนึ่ง = บรรทัด 23 = บรรทัดtruncate(post.body.to_plain_text …)ที่เพิ่งแก้ (กฎ "ไฟล์ .erb ลบหนึ่ง" จากบท 113)(13 queries)= 3 ตัวปกติ + 10 ตัวของ rich text · bullet (บท 125) จับได้และบอกให้includes
แก้ที่ 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])
record_id IN (30, 29, …, 21)— ไปลิ้นชักกลางรอบเดียว หยิบมาทั้ง 10 แผ่น ·13 → 4 queries- แต่ bullet กลับตะโกน
AVOID eager loading= "หยิบมาล่วงหน้าแล้วไม่ได้ใช้ เอาออกซะ" · คำเตือนนี้ผิด — ตัวเลขใน log บอกชัดว่าใช้จริง (ถ้าเอาออกจะกลับไป 13)
ทำไมเตือนผิด: ในโค้ดของ 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
add_safelist— "รายการยกเว้น" ของ bullet ·type: :unused_eager_loading= เฉพาะคำเตือนแบบ AVOID ·class_name+association= เฉพาะ Post กับ rich_text_body · คำเตือนอื่นยังทำงานครบ- comment สองบรรทัดสำคัญพอ ๆ กับโค้ด — กันคนหรือ AI รุ่นหลังทำตามคำเตือนแล้วลบ
with_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
- เดิม:
title, description = …และpost.description = description· ใหม่: ตั้งชื่อตัวแปรว่าtextแล้วใส่ลงpost.body·description/plain_textมาจาก callback เอง - ใส่ตัวหนังสือล้วนลง body ตรง ๆ ได้เพราะข้อความใน seeds ไม่มี
<>&· seeds ไม่แตะบทความที่มีอยู่แล้ว (ค้นด้วย title) → DB เดิมของคุณยังต้องพึ่งposts:backfill_bodyที่รันไปแล้ว
ดูว่าแตะอะไรไปบ้าง แล้ว 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
- 18 ไฟล์ —
A= ไฟล์ใหม่ ·M= แก้ของเดิม · ไม่มีD(ไม่ได้ลบอะไร) ·app/assets/stylesheets/actiontext.cssไม่อยู่ในรายการเพราะย้ายไปก่อนเคย commit - เลขหน้าชื่อ migration · เลข commit (
381409b) ของคุณต่างจากนี้ได้ · บรรทัดcreate mode= รายชื่อไฟล์ใหม่ 8 ไฟล์ (ตรงกับตัวAข้างบน) · ถ้าจำนวนไฟล์ไม่ใช่ 18 ให้เทียบรายการด้านบนว่าขาดหรือเกินตัวไหน
ให้ 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 ส่งกลับมาด้วยข้อพวกนี้:
git diffต้องไม่มีremove_column :…, :descriptionและไม่มี migration ที่เรียก model ข้างใน- รัน rake task สองรอบ · รอบสองต้องไม่สร้างแถวซ้ำ (
ActionText::RichText.countเท่าเดิม) grep -rn "raw\|html_safe" app/views(-r= ค้นทุกไฟล์ในโฟลเดอร์ ·\|= "หรือ") ต้องไม่เจอที่ใช้กับ body- เปิดหน้ารายการ ดู
(N queries)ใน log · ถ้า AI ลบwith_rich_text_bodyตามคำเตือน AVOID ของ bullet → ตัวเลขจะพุ่งกลับ - ลองบันทึกเนื้อหาที่มี
<script>alert(1)</script>ใน console แล้วcurlหน้า show ต้องไม่เห็นแท็ก script
🎯 ภารกิจ — เขียนบทความผ่านหน้าเว็บ แล้วตามไปดูว่าแต่ละส่วนไปอยู่ไหน (~10 นาที)
ใช้โค้ดที่ commit แล้ว · bin/dev เปิดอยู่
- เปิด
localhost:3000/posts/new· หัวข้อร้านลับย่านบางรัก· หมวดอะไรก็ได้ - ในช่องเนื้อหา: กดปุ่ม B พิมพ์
เมนูเด็ดกด B อีกครั้งให้ปิด · Enter · กดปุ่มรายการแบบจุด (ไอคอนสามขีดมีจุด) พิมพ์ต้มยำ· Enter · พิมพ์แกงเขียวหวาน· กด "บันทึก" - เปิด
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 · แอปของคอร์ส) | ต้องสนใจไหม |
|---|---|---|---|
| 5 | action_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 | สนใจ — อย่าลบตามคำเตือน |
| 6 | ActionText::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 ทุกครั้งที่บันทึก (เขียนคู่) เพราะคอลัมน์ห้ามว่าง และให้ถอยโค้ดกลับได้ | ไม่ต้อง — ทำตามบทนี้ก็ครบ |
อ้างอิง
- Rails Guides — Action Text Overview § Installation —
อ่าน Installation ต่อถึง Creating Rich Text Content · ตรงกับขั้น 1–2 และ 5 ของบทนี้ (
has_rich_text·rich_textarea· params) - Rails Guides — Action Text Overview § Rendering Rich Text Content —
อ่านหัวข้อนี้หัวข้อเดียว (สั้น): ยืนยันว่า
<%= @article.content %>ปลอดภัยเพราะ sanitize แล้ว · และเตือนว่าto_plain_textไม่ได้ล้าง · หัวข้อถัดไป (5.1) บอกที่มาของกรอบtrix-content - Rails Guides — Action Text Overview § Avoiding N+1 Queries —
หัวข้อสั้น ๆ ท้ายหน้า ·
with_rich_text_contentกับ…_and_embeds - Rails API — ActionText::Attribute#has_rich_text —
อ่านตัวอย่างและ Options · เห็นว่ามันสร้าง
has_one+ scopewith_rich_text_…ให้ - GitHub — rails/rails v8.1.4 · action_text/content_helper.rb —
ไฟล์สั้น · ดู method
sanitizer_allowed_tagsท้ายไฟล์ ว่าทำไมallowed_tagsเป็น nil แต่ยังมีรายการ 46 แท็ก - Rails Guides — Active Record Associations § Polymorphic Associations —
อ่านแค่ย่อหน้าแรกกับตัวอย่าง Picture · เทียบ
imageable_type/imageable_idกับrecord_type/record_idของเรา - Rails Guides — The Rails Command Line § Custom Rake Tasks —
ที่มาของ
namespace·desc·task …: :environmentในlib/tasks/posts.rake
ติดตรงไหน หรือผลของคุณต่างจากในบท — ก็อปผลจริงทั้งก้อนไปถาม AI หรือเพื่อนได้เลย โดยบอกว่ามาจากบท 137 ขั้นไหน