ข้ามไปยังเนื้อหา
Pre-MVP โค้ดเบส 1.0 ยังไม่ใช่ผลิตภัณฑ์ที่เผยแพร่ — ดู ขอบเขตผลิตภัณฑ์ 1.0

สัญญาของเอกสารหน้า (page-document contract)

บันทึกสำหรับนักพัฒนาของ backend/src/lib/page-contract ไม่ใช่ specification ขอบเขตของผลิตภัณฑ์คือ SPEC.md §4.2 และ §10 สัญญาของ renderer ที่ export ออกไปคือ SPEC.md §10.2 ขอบเขตของ renderer ที่อ่านผ่าน API แบบ live คือ SPEC-RENDERER.md §5.1 และ §6

นิยามเดียวของเอกสารหน้า Dee Wan ที่ไม่มี dependency และไม่ผูกกับ framework ใด โมดูลนี้ export ค่าคงที่ของรูปแบบ (format) คำศัพท์ของ slide, type ที่แน่นอนของ slide และเอกสาร, ขีดจำกัด, parser, รหัสการปฏิเสธ และชุด fixture สำหรับทดสอบความสอดคล้อง (conformance)

backend/src/lib/page-contract/
refusal.ts DOCUMENT_REFUSALS, Refusal, Parsed, refuse, accept
document.ts PAGE_FORMAT, SLIDE_TYPES, LIMITS, types, parsers
fields.ts SLIDES_FIELD_KEY, TITLE_FIELD_KEY, SLIDES_FIELD_TYPE, isPageModel
fixtures.ts ACCEPTED_PAGES, REFUSED_PAGES, REFUSED_MODEL_SLIDES, STORED_DELETED_MEDIA
format2.ts PAGE_FORMAT_2, PAGE_FORMATS, SECTION_LIMITS, section types
renderer.ts renderablePage, parseAnyPageDocument, parsePublicRenderablePage, declaredFormat
migrate.ts migrateGroupToFormat2
group-structure.ts structureDrift, requireSameStructure, groupStructure, localeSaveKeepsStructure
index.ts barrel — everything except the fixtures

fixture ถูกแยกไว้นอก barrel โดยตั้งใจ เพราะเป็นข้อมูลทดสอบ และ barrel ที่ดึง fixture เข้ามาด้วย จะทำให้ fixture ทุกตัวถูกรวมเข้าไปในทุก bundle ที่อ่านหน้าเพียงหน้าเดียว

ชั้น เป็นเจ้าของ
page-contract รูปแบบเอกสาร, คำศัพท์ของ slide, ขีดจำกัด, parser, รหัสการปฏิเสธ
page-composer operation, การวางแผน, prompt ของโมเดล, นโยบายการเขียนของ admin (COMPOSER_OWNED_FIELD_KEYS)
api/pages.ts การตรวจสิทธิ์, การสร้าง media lookup, การเขียนตาม lifecycle, การแปลงเป็น HTTP status
frontend/src/lib/pages/slides.ts การแสดงผลเท่านั้น — ป้ายชื่อ section และ projection ของ outline
renderer ภายนอก HTML, CSS, layout, routing, response header

page-composer/errors.ts สร้าง COMPOSER_ERRORS เป็น [...DOCUMENT_REFUSALS, ...composer-only] รหัสของเอกสารจะไม่ถูกประกาศซ้ำที่นั่นเลย

  • Backend: page-composer/operations.ts, page-composer/planner.ts, api/pages.ts, lib/lifecycle/service.ts
  • Frontend: src/lib/pages/slides.ts re-export มันผ่าน relative path (../../../../backend/src/lib/page-contract) แบบเดียวกับที่ type ของ $prisma/generated เข้าถึง แอป admin ไม่มีอะไรถูกคัดลอกด้วยมือ

ไม่มีชุดคำศัพท์ที่เขียนด้วยมือชุดที่สาม และ frontend/cypress/specs/page-composer-parity.test.ts จะ fail หากมีชุดนั้นเกิดขึ้น

ทั้งสามตัว parse เอกสารเดียวกัน สิ่งที่ต่างกันคือกฎของ media และความหมายของเอกสารที่ไม่มีอยู่

จุดเข้า ที่มา Media เอกสารที่ไม่มีอยู่ ผู้เรียก
parseSlide(raw, id, {origin:'model', media}) คำตอบของ language model resolve ผ่าน MediaLookup ของไซต์ แล้วตรวจค่าที่ resolve ได้กับสัญญาของ media ที่บันทึกไว้ id ที่ resolve ไม่ได้คือ unknown_media ไม่เกี่ยวข้อง การสร้างและการแก้ไข
parsePageDocument(raw, {origin:'stored', media}) เอกสารที่ผลิตภัณฑ์นี้เขียน resolve ผ่าน lookup แถวที่ resolve ได้แต่ไม่ผ่านสัญญา และแถวที่ถูกลบ จะถอยกลับไปใช้ reference ที่บันทึกไว้ทั้งคู่ หน้าว่าง composer ของ admin
parsePublicPageDocument(raw) fields.slides ของ envelope สาธารณะ ไม่มี lookup เลยmedia_id, url และ alt ที่บันทึกไว้จะถูกตรวจตามที่เป็นอยู่ unreadable_page renderer

ทุกค่าที่ส่งถึงตัวใดตัวหนึ่งในนี้ถือว่าไม่น่าเชื่อถือ

ทุกเอกสารที่ create หรือ revise บันทึกลงไปต้องผ่าน parsePublicPageDocument มีแนวป้องกันอิสระสองชั้น ที่รักษาข้อนี้ไว้ และ test/page-contract-publishable.test.ts พิสูจน์แต่ละชั้นแยกกัน:

  1. Media ที่ resolve แล้วจะถูกตรวจ MediaLookup ตอบจาก media.url ซึ่งเป็น string ที่เก็บไว้ การ import สามารถเขียนอะไรก็ได้ลงไป — media-import.ts ตรวจเพียงว่า source_url ไม่ว่าง — ดังนั้นแถวที่เก็บ javascript:alert(1) เคยทำให้เกิดหน้าที่บันทึกแล้วแต่ public parser ปฏิเสธ ตอนนี้ media ที่มาจากโมเดล ถูกตรวจกับสัญญาของ media ที่บันทึกไว้ชุดเดียวกับที่การอ่านแบบสาธารณะใช้ และหน้าจะถูก ปฏิเสธ ไม่ใช่ถูกตัดรูปภาพทิ้ง
  2. เอกสารทั้งฉบับจะถูกอ่านซ้ำก่อนการเขียนทุกครั้ง api/pages.ts รัน parsePublicPageDocument กับเอกสารที่สร้างขึ้นทั้งใน handler ของ create และ revise ก่อน saveContent หากไม่ผ่าน จะไม่มีอะไรถูกบันทึก

ชั้นที่สองซ้ำซ้อนตราบใดที่ชั้นแรกถูกต้อง และนั่นคือประเด็น: การ mutate parser อย่างเดียว route ยังคงปฏิเสธอยู่ ต้อง mutate ทั้งสองชั้นจึงจะปล่อยหน้าที่ไม่ดีผ่านไปได้

ผลที่ต้องรู้ไว้ หน้าที่ถูกบันทึกไว้ก่อนกฎนี้มีอยู่ และมีแถว media ที่ใช้ไม่ได้ ยังคง เปิด ได้ใน admin — มิฉะนั้นจะไม่มีทางแก้ไขให้เป็นหน้าที่ถูกต้องได้เลย — แต่จะ บันทึก ไม่ได้จนกว่าจะแก้ media หรือลบ slide นั้นออก การปฏิเสธจะระบุชื่อฟิลด์ นี่เป็นความตั้งใจ: ทางเลือกอื่นคือการทิ้งรูปภาพที่ผู้แก้ไขไม่ได้ขอให้ลบ อย่างเงียบ ๆ

การ render หน้าที่เผยแพร่แล้วต้องไม่ต้องการฐานข้อมูลของ admin เลย พารามิเตอร์ media-lookup คือ ฐานข้อมูลที่ปลอมตัวมา: ทันทีที่ signature มีพารามิเตอร์นี้ renderer ก็ต้องมีบางอย่างส่งเข้าไป test/page-contract-boundary.test.ts assert ว่า parsePublicPageDocument.length === 1

  • Media ที่ไม่รู้จัก มีเพียง 'model' ที่ทำให้เกิด unknown_media ได้ นี่คือตัวป้องกันข้าม tenant สำหรับ media ของ slide: lookup สร้างจากแถว media ของไซต์นี้ ดังนั้น id จากที่อื่นใดจะ resolve ไม่ได้อะไรเลย
  • แถว media ที่ถูกลบ 'stored' เก็บสิ่งที่เอกสารบันทึกไว้ ดังนั้นการลบรูปภาพหนึ่งรูปจะไม่ทำให้หน้าที่มีอยู่ อ่านไม่ได้และจึงแก้ไขไม่ได้ 'public' อ่านค่าที่บันทึกไว้ชุดเดียวกันและได้ media เดียวกัน — เมื่อแถวหายไป แล้ว เอกสารคือแหล่งข้อมูลเดียวที่ทั้งสองฝั่งมี
  • ออบเจกต์ media ที่บันทึกไว้แต่ผิดรูป 'stored' เก็บสิ่งที่เอกสารถืออยู่และไม่เคยปฏิเสธ ดังนั้นหน้าที่ เสียหายยังคงเปิดได้และจึงแก้ไขได้ใน admin 'public' ปฏิเสธทั้งหน้า ความต่างนี้เป็นความตั้งใจ และเป็น จุดเดียวที่การอ่านทั้งสองแบบต่างกัน; ด่านตรวจก่อนบันทึกข้างต้นคือสิ่งที่ป้องกันไม่ให้มันกลายเป็นช่องทาง บันทึกหน้าที่ render ไม่ได้
  • เอกสารที่ไม่มีอยู่ 'stored' อ่านเป็นหน้าว่าง — หน้าที่รอการสร้าง 'public' ปฏิเสธ: การ render เอกสารว่างจะทำให้เกิดหน้าเปล่าที่ index ได้บนไซต์ที่ใช้งานจริงภายใต้ status 200

SLIDE_MEDIA_KEYS ประกาศ key ที่ค่าเป็น SlideMedia ตามแต่ละประเภทของ slide พร้อม accessor ที่อ่านค่านั้น pageMediaReferences(document) สร้างมาจากสิ่งนี้ และคืน media reference ทุกตัว ที่เอกสารมี พร้อม id ของ slide, ประเภทของ slide และ key ที่พบ

Graduation คือผู้เรียกที่ต้องใช้สิ่งนี้ การตรวจการดูแล media (media-custody) ของมันสแกนคอลัมน์ <key>_id ที่สร้างขึ้น และ media ของหน้าไม่ได้อยู่ในคอลัมน์ — มันอยู่ในเอกสาร slides — ดังนั้นหน้าที่มี media แต่ถูกอ่านว่าไม่มี media จะทำให้ export ไซต์ที่รูปภาพ resolve ได้เฉพาะกับ CMS เท่านั้น

การประกาศนี้เข้าถึงได้เฉพาะ key ของ slide ระดับ บนสุด ประเภท slide ที่ซ้อน media reference ไว้ใน list ต้องขยาย shape แทนที่จะประกาศ key ของ list; การทดสอบ corpus จะไล่ดูเอกสารที่ parse แล้ว เพื่อหาคู่ media_id/url ใดก็ตามที่การประกาศเข้าไม่ถึง

URL ของ media ที่บันทึกไว้จะถูก parse ด้วย new URL ไม่ใช่เทียบ prefix startsWith('https://') ไม่ใช่การตรวจ URL: มันยอมรับ https:// ที่ไม่มี host, https://user:pass@cdn.example/a.jpg และ — เพราะเบราว์เซอร์ normalise backslash เป็น slash — /\evil.example/a.jpg ซึ่ง resolve จาก https://site.example ไปเป็น https://evil.example/a.jpg

รูปแบบ ผลลัพธ์
https://cdn.example/a.jpg (อนุญาต query และ fragment) ยอมรับ
/local-media/abc — มี slash นำหน้าเพียงตัวเดียวพอดี ยอมรับ
http://localhost…, http://127.0.0.1…, http://[::1]… ยอมรับ
http:// กับ host อื่นใด ปฏิเสธ — mixed content บนหน้าที่เผยแพร่
https:// ที่ไม่มี host ปฏิเสธ
https://user:pass@host/a.jpg ปฏิเสธ — credential รั่วผ่าน referrer และ cache
//evil.example/a.jpg ปฏิเสธ — protocol-relative อยู่นอก origin
/\evil.example/a.jpg หรือ backslash ใดก็ตาม ปฏิเสธ
javascript:, data:, scheme อื่นใด ปฏิเสธ
มี whitespace หรือ markup แทรกอยู่, ยาวเกิน LIMITS.href ปฏิเสธ

HTTP แบบ loopback ได้รับอนุญาตเพียงเพราะ local rig ให้บริการ media จาก http://localhost:8787/local-media/…; media ใน production ต้องเป็น HTTPS หรือ site-relative

Alt text ถูกจำกัดขนาดและตรวจ type แต่ ไม่ ตรวจ markup มันคือคำบรรยาย media ที่ผู้เขียนเขียนเอง ไม่ใช่ผลลัพธ์จากโมเดล และลงไปอยู่ใน attribute ที่ escape แล้ว; การปฏิเสธหน้าเพราะวงเล็บมุมในคำบรรยาย จะทำให้ไซต์พังเพราะเครื่องหมายวรรคตอน

ลิงก์นำทางเข้มงวดกว่า URL ของ media: site-relative, https:// หรือ mailto: เท่านั้น

frontend/src/lib/pages/slides.ts export readPageDocument(value) ซึ่งคืน { ok: true, document } หรือ { ok: false, code, message }

มันไม่ใช่ type predicate โดยตั้งใจ guard แบบ value is PageDocument นั้น unsound: parser ยอมรับ shape มากกว่าที่มันผลิต — string JSON ที่ serialize แล้ว และเอกสารที่ละ key ที่ไม่บังคับไว้ แทนที่จะเป็น null — และ normalise ให้ การรายงาน true เกี่ยวกับค่าต้นฉบับทำให้ compiler เข้าใจว่า string มี .slides

มีเพียง parsed.value ที่มี shape ตามที่สัญญาไว้ ดังนั้น fetch-store/pages.ts จึงแทนที่ document ของ response เองด้วยตัวที่ parse แล้ว ในการอ่านและเขียนหน้าทุกครั้ง เพื่อไม่ให้หน้าจอใดอ่าน slide ที่ยังไม่ normalise

การปฏิเสธคือ { ok: false, error: { code, message, detail } } — ออบเจกต์ธรรมดา ไม่ใช่ error ที่ถูก throw และไม่ใช่ class การตรวจ instanceof ข้ามขอบเขตของ package จะล้มเหลวเมื่อมี class เดียวกัน สองสำเนา และ error ที่ถูก throw ไม่รอดผ่าน JSON api/pages.ts แปลงรหัสเป็น status; frontend แปลงเป็นข้อความใน COMPOSER_REFUSAL_COPY

  1. เพิ่มลงใน SLIDE_TYPES เพิ่ม type ที่แน่นอนของมัน และเพิ่มลงใน union Slide
  2. เพิ่ม entry ใน SLIDE_KEYS, SLIDE_OPTIONAL_KEYS, SLIDE_VARIANT_KEYS และ SLIDE_MEDIA_KEYS — ทั้งสี่ตัวใช้ SlideType เป็น key ดังนั้นขั้นตอนนี้จะเป็น compile error จนกว่า จะทำเสร็จ SLIDE_MEDIA_KEYS ถูกตรวจเข้มกว่าตัวอื่น: ประเภท slide ที่ถือ SlideMedia ห้ามประกาศ [] และ key ที่ไม่ใช่ media ห้ามประกาศเลย
  3. เพิ่ม case ใน parseSlide
  4. เพิ่ม fixture ใน ACCEPTED_PAGES ที่ครอบคลุมประเภทนั้น โดยมีค่าที่ไม่บังคับครบทุกค่า, ค่าที่ไม่บังคับ เป็น null ทุกค่า และค่า enum ครบทุกค่า
  5. เพิ่มป้ายชื่อใน SLIDE_LABELS และ branch ในแต่ละ projection ใน frontend/src/lib/pages/slides.ts (slideOwnHeading, slideLead, slideParagraphs, slideFeatures, slideMedia, slideLink) — ทุกตัวเป็น switch แบบ exhaustive ดังนั้นนี่ก็เป็น compile error เช่นกันจนกว่าจะทำเสร็จ
  6. เพิ่ม component ของ renderer ใน repository ของ renderer

ขั้นตอนที่ 1–5 จะ fail อย่างชัดเจนหากข้ามไป test/page-contract-corpus.test.ts fail กับประเภทที่ไม่มี fixture; frontend/src/lib/pages/slides.test.ts fail กับประเภทที่ admin ไม่วาดอะไรให้; PagePreview.svelte.test.ts fail กับประเภทที่ไม่ไปถึง card ใดเลย; test/page-contract-media.test.ts fail กับเอกสารที่ถือ media reference ที่การประกาศเข้าไม่ถึง ซึ่งเป็นกรณีที่การประกาศเฉพาะระดับบนสุดจะพลาดไป

ประเภท slide ใหม่ ไม่ ต้องเพิ่มเลข format การเพิ่มประเภทขยายสิ่งที่เก็บได้ แต่ไม่ได้เปลี่ยนวิธีอ่าน เอกสารที่มีอยู่

format คือขอบเขตของ migration parsePageDocument ปฏิเสธทุกอย่างที่ไม่ใช่ PAGE_FORMAT พอดี และ parseAnyPageDocument ปฏิเสธทุกอย่างที่อยู่นอก PAGE_FORMATS — format ที่ไม่มีและ format ที่ใหม่กว่าจะ fail closed การยอมรับ format ที่ใหม่กว่าจะเป็นการอ่านฟิลด์ของ build ที่ใหม่กว่าด้วยกฎของวันนี้ ทิ้งสิ่งที่ไม่รู้จัก และเขียนผลการอ่านที่สูญเสียข้อมูลนั้นกลับทับเอกสารจริง

การเพิ่มเลข format ต้องมีสิ่งต่อไปนี้ตามลำดับ:

  1. migration ของ core ที่เขียนเอกสารที่เก็บไว้ใหม่ หรือการตัดสินใจเรื่องความเข้ากันได้ที่เขียนไว้อย่างชัดเจน;
  2. อัปเดตสัญญา;
  3. ออก release ที่ renderer รองรับ;
  4. conformance fixture สำหรับทั้งสอง format;
  5. ลำดับการอัปเกรดที่มีเอกสารไว้สำหรับผู้รับไปดูแล (adopter)

อย่าเพิ่มเลข PAGE_FORMAT เพื่อเพิ่มฟิลด์หรือประเภท slide

PageDocument.format มี type เป็น typeof PAGE_FORMAT ไม่ใช่ number ดังนั้น { format: 999, slides: [] } เป็นทั้ง compile error และการปฏิเสธตอน runtime test/page-contract-corpus.test.ts รักษาข้อนี้ไว้ด้วย บรรทัด @ts-expect-error ที่จะ compile ไม่ผ่านหาก type ถูกขยายกว้างขึ้นอีก

fixtures.ts เขียนด้วยมือโดยตั้งใจ test/page-contract-corpus.test.ts อ่าน SLIDE_TYPES, SLIDE_OPTIONAL_KEYS, SLIDE_VARIANT_KEYS, LIMITS และ DOCUMENT_REFUSALS และจะ fail จนกว่า corpus จะทดสอบครบทุกตัว:

  • ทุกประเภท slide ปรากฏในเอกสารที่ยอมรับและ parse แล้ว;
  • ทุก key ที่ไม่บังคับปรากฏทั้งแบบไม่เป็น null และเป็น null;
  • ทุก key แบบ enum ปรากฏครบทุกค่า;
  • ทุกรหัสการปฏิเสธถูกผลิตโดย fixture ที่ถูกปฏิเสธบางตัว;
  • ทุกขีดจำกัดถูกข้ามโดย fixture ที่ถูกปฏิเสธซึ่ง detail ของการปฏิเสธมี max ค่านั้นพอดี

ความครอบคลุมวัดจากสิ่งที่ parser คืนมา ไม่ใช่จากสิ่งที่ fixture อ้าง corpus ที่สร้างจาก list เหล่านั้น จะตรงกับมันโดยโครงสร้างและไม่มีทาง fail ได้ — อย่าเขียนแบบนั้น

ACCEPTED_PAGES และ REFUSED_PAGES ถูกตัดสินโดย parsePublicPageDocument พฤติกรรมของที่มาแบบ model และแบบ stored ถูกแยกไว้ใน REFUSED_MODEL_SLIDES และ STORED_DELETED_MEDIA

สัญญานี้ต้องรันใน workerd ได้โดยไม่มี CMS อยู่รอบ ๆ มัน import ได้เฉพาะไฟล์ข้างเคียงของตัวเองเท่านั้น — ไม่มี Hono, ไม่มี Prisma, ไม่มี Svelte, ไม่มี Astro, ไม่มีโมดูล node:, ไม่มีโค้ดฐานข้อมูล

test/page-contract-boundary.test.ts assert ข้อนี้สองครั้งอย่างเป็นอิสระต่อกัน:

  1. อ่าน module specifier ทุกตัวในไดเรกทอรีและกำหนดให้แต่ละตัวต้องตรงกับ ^\./[A-Za-z0-9_-]+$;
  2. bundle index.ts ด้วย esbuild ที่ platform: 'neutral' หลัง resolver plugin ที่ปฏิเสธ specifier ที่ไม่ใช่ relative ทุกตัว แล้วตรวจผลลัพธ์หา require( และ node:

ไม่มีตัวใดรัน workerd จริง เมื่อรวมกันแล้วพิสูจน์ว่าโมดูลเป็นอิสระในตัวเอง ซึ่งเป็นคุณสมบัติที่ build ของ workerd ต้องการ

ปัจจุบันสัญญาอยู่ใน repository นี้และถูกใช้ผ่าน relative path repository ของ renderer จะใช้ artifact ของไดเรกทอรีเดียวกันที่ถูก pin และเปลี่ยนแปลงไม่ได้ และต้องไม่คัดลอก type ด้วยมือเด็ดขาด การอัปเกรด สัญญาจึงจะกลายเป็นการเปลี่ยน dependency อย่างชัดแจ้ง

ชื่อของ artifact, ตำแหน่ง และกลไกการเผยแพร่ ยังไม่ได้ตัดสินใจ และต้องได้อำนาจจากเจ้าของโปรเจกต์ ไม่มีสิ่งใดใน repository นี้ระบุชื่อเหล่านั้น จนกว่าจะมีสิ่งเหล่านั้น ให้ถือว่า surface ที่ export ออกไปเปลี่ยนแปลงไม่ได้แล้ว: การเปลี่ยนแปลงแบบเพิ่มเติมมีต้นทุนต่ำ การเปลี่ยนชื่อและการเปลี่ยน shape ไม่ใช่

renderer ต้องการสิ่งจาก artifact เพียงสามอย่างพอดี:

  • parsePublicPageDocument — การตรวจสอบทั้งหมด, argument เดียว, ไม่มีฐานข้อมูล;
  • SLIDE_TYPES และ type ของ slide — สำหรับ component registry ตอน build ที่ exhaustive ตอน compile;
  • ACCEPTED_PAGES / REFUSED_PAGES — เพื่อพิสูจน์ว่า parser ของตัวเองเห็นตรงกับของ core

มันไม่ควร import page-composer เพื่อสิ่งใดเลย