Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

RFC-0005: configuration hierarchy and post lifecycle

Version: 0.2.2 | Status: normative | Phase: impl


1. Summary

[RFC-0005:C-SUMMARY] Summary (Informative)

This RFC defines the configuration hierarchy and post lifecycle management for typub.

Scope:

  • Three-layer configuration system (global, per-content, platform-specific) with 5-level resolution
  • Resolution order for configuration values
  • Draft/publish lifecycle states and transitions
  • Platform capability declarations for lifecycle support
  • Status tracking schema for remote state

Out of scope:

  • Platform-specific API details (covered by adapter implementations)
  • Content rendering pipeline (covered by RFC-0002)

Rationale: As typub supports more platforms with varying draft/publish capabilities, a unified abstraction is needed to:

  1. Allow per-content override of global settings
  2. Declare platform capabilities in a type-safe manner
  3. Track remote lifecycle state for correct transition handling
  4. Provide consistent behavior across platforms with different API models

Since: v0.1.0


2. Specification

[RFC-0005:C-CONFIG-LAYERS] Configuration Layers (Normative)

typub MUST support a three-layer configuration system:

  1. Global configuration (typub.toml): Project-wide defaults that apply to all content items unless overridden.

  2. Per-content configuration (meta.toml): Content-specific settings that override global defaults for that content item.

  3. Platform-specific configuration: Both global and per-content configurations MAY include platform-specific sections.


Configuration File Locations

  • Global configuration: typub.toml at the project root.
  • Per-content configuration: meta.toml within each content directory.

The directory containing typub.toml defines the project root. See RFC-0005:C-PROJECT-ROOT for path resolution semantics.


Configuration Structure

Global configuration (typub.toml):

# Global defaults (layer 4)
published = true

# Global platform-specific (layer 3)
[platforms.hashnode]
published = false

[platforms.devto]
published = true

Per-content configuration (meta.toml):

title = "My Post"
created = "2026-01-15"

# Per-content default (layer 2)
published = false

# Per-content platform-specific (layer 1)
[platforms.hashnode]
published = true

Rationale: This layered approach allows users to set sensible defaults while retaining fine-grained control. The three-layer system (global, per-content, platform-specific) combined with nested platform sections creates a 5-level resolution chain that covers all practical use cases without excessive complexity.

Since: v0.1.0

[RFC-0005:C-RESOLUTION-ORDER] Resolution Order (Normative)

For any configuration key, the implementation MUST resolve values using a 5-level fallback chain:

  1. meta.toml[platforms.<platform_id>].<key> — per-content platform-specific
  2. meta.toml<key> — per-content default
  3. typub.toml[platforms.<platform_id>].<key> — global platform-specific
  4. typub.toml<key> — global default
  5. Adapter-defined default value

The first non-null value found MUST be used. If no value is found at any level, the adapter-defined default MUST apply.


Resolution Algorithm

#![allow(unused)]
fn main() {
fn resolve<T>(
    content_meta: &Meta,
    platform_id: &str,
    global_config: &Config,
    adapter_default: T,
    key: impl Fn(&PlatformConfig) -> Option<T>,
    global_key: impl Fn(&Config) -> Option<T>,
    content_key: impl Fn(&Meta) -> Option<T>,
) -> T {
    // Layer 1: per-content platform-specific
    content_meta.platforms.get(platform_id).and_then(&key)
        // Layer 2: per-content default
        .or_else(|| content_key(content_meta))
        // Layer 3: global platform-specific
        .or_else(|| global_config.platforms.get(platform_id).and_then(&key))
        // Layer 4: global default
        .or_else(|| global_key(global_config))
        // Layer 5: adapter default
        .unwrap_or(adapter_default)
}
}

Example: Resolving published for Hashnode

Given:

  • meta.toml: no [platforms.hashnode] section, published = false at top level
  • typub.toml: [platforms.hashnode].published = true, no global published
  • Adapter default: true

Resolution:

  1. Check meta.toml[platforms.hashnode].published → not found
  2. Check meta.toml.publishedfound: false → use this value

Result: published = false


Example: Resolving published for Dev.to

Given:

  • meta.toml: no relevant settings
  • typub.toml: [platforms.devto].published = false, published = true at top level
  • Adapter default: true

Resolution:

  1. Check meta.toml[platforms.devto].published → not found
  2. Check meta.toml.published → not found
  3. Check typub.toml[platforms.devto].publishedfound: false → use this value

Result: published = false


Rationale: The 5-level resolution order follows the principle of specificity: more specific configurations override more general ones. Per-content settings always take precedence over global settings, and platform-specific settings take precedence over general settings within the same scope.

Since: v0.1.0

[RFC-0005:C-DRAFT-SUPPORT] Draft Support Capability (Normative)

Each platform adapter MUST declare its draft support capability using the DraftSupport enum:

#![allow(unused)]
fn main() {
pub enum DraftSupport {
    /// Platform has no draft concept. Content is always published immediately.
    None,

    /// Same object with a status field that can be toggled.
    /// `reversible` indicates whether publish -> draft transition is supported.
    StatusField { reversible: bool },

    /// Draft and published content are separate objects with different IDs.
    /// Transition from draft to published requires a platform-specific mutation.
    SeparateObjects,
}
}

The AdapterCapability struct MUST include a draft_support field:

#![allow(unused)]
fn main() {
pub struct AdapterCapability {
    pub id: &'static str,
    pub name: &'static str,
    pub tags: CapabilitySupport,
    pub categories: CapabilitySupport,
    pub internal_links: CapabilitySupport,
    pub draft_support: DraftSupport,  // NEW
    pub notes: &'static str,
}
}

Rationale: Different platforms have fundamentally different models for draft content:

  • Some platforms (Dev.to, Ghost, WordPress, Confluence) use a status field on the same object
  • Some platforms (Hashnode) maintain separate draft and published objects
  • Some platforms (Notion) have no draft concept at all

Declaring this capability allows the pipeline to handle transitions correctly.

Since: v0.1.0

[RFC-0005:C-LIFECYCLE-TRANSITIONS] Lifecycle Transitions (Normative)

State transitions MUST follow platform-specific rules based on the platform type.

Platform Classification:

Platforms are classified into two categories:

  1. API-based platforms: Platforms with remote APIs (Hashnode, Dev.to, Ghost, WordPress, Confluence, Notion)
  2. Local output platforms: Platforms that produce local files or clipboard output (Astro, Xiaohongshu, Copypaste)

Local Output Platform Rules

For local output platforms, the lifecycle decision is trivial:

  • The published configuration MUST be ignored.
  • The implementation MUST always write/update the local output.
  • No draft/publish lifecycle tracking is applicable.
  • The implementation MAY store a status row for change detection (content_hash).

API-based Platform Rules

For API-based platforms, the implementation MUST apply the following decision logic.

Precondition check:

Before applying the decision table, the implementation MUST determine:

  1. has_remote_object: Whether platform_id is present in the status row
  2. remote_status: The stored lifecycle state (“draft” or “published”)
  3. desired_published: The resolved published configuration value per RFC-0005:C-RESOLUTION-ORDER
  4. draft_support: The platform’s declared DraftSupport capability

Data integrity guard:

If has_remote_object = true and remote_status is not one of {"draft", "published"}:

  • The implementation MUST fail with a diagnostic error.
  • The error message MUST indicate the invalid remote_status value and the affected content/platform.
  • The implementation MUST NOT proceed with any API operation.

This guard ensures corrupted or incomplete status data does not cause undefined behavior.

Decision Table (API-based platforms only):

has_remote_objectremote_statusdesired_publishedDraftSupportAction
false-trueAnyCreate as published
false-falseStatusField/SeparateObjectsCreate as draft
false-falseNoneCreate as published (ignore config)
true“published”trueAnyUpdate existing published content
true“published”falseStatusField { reversible: true }Update status to draft
true“published”falseStatusField { reversible: false }Warn: cannot unpublish; update content only
true“published”falseSeparateObjectsWarn: cannot unpublish; update content only
true“published”falseNoneUpdate published content (ignore config)
true“draft”trueStatusFieldUpdate status to published
true“draft”trueSeparateObjectsExecute publishDraft mutation
true“draft”trueNoneN/A (None never creates draft)
true“draft”falseStatusField/SeparateObjectsUpdate existing draft content
true“draft”falseNoneN/A (None never creates draft)

DraftSupport-specific Behaviors

DraftSupport::None (API-based):

  • The published configuration MUST be ignored.
  • Content MUST always be created/updated as published.
  • The implementation MUST store remote_status = "published" after each operation.

DraftSupport::StatusField:

  • If reversible is true: Both draft-to-publish and publish-to-draft transitions are permitted.
  • If reversible is false: Only draft-to-publish is permitted; publish-to-draft MUST log a warning and update content without changing status.

DraftSupport::SeparateObjects:

  • Draft-to-publish: MUST use platform-specific “publish draft” mutation.
  • After successful publish, the implementation MUST:
    • Update platform_id to the new published object ID.
    • Update remote_status to “published”.
    • Update url to the public URL.
  • Publish-to-draft: MUST NOT be supported; MUST log a warning and update content without changing status.

Rationale: Separating local output from API-based platforms eliminates data model confusion. The data integrity guard ensures corrupted status rows cause immediate, diagnosable failures rather than silent misbehavior.

Since: v0.1.0

[RFC-0005:C-STATUS-TRACKING] Status Tracking (Normative)

The status database tracks publish results and remote state.

Schema:

CREATE TABLE IF NOT EXISTS platform_status (
    slug TEXT NOT NULL,
    platform TEXT NOT NULL,
    published INTEGER NOT NULL,
    url TEXT,
    platform_id TEXT,
    published_at TEXT,
    content_hash TEXT,
    remote_status TEXT,
    PRIMARY KEY (slug, platform)
);

Row Persistence Policy

API-based platforms: The implementation MUST store a status row for each content item created or updated on an API-based platform. This includes both draft creation/updates and published content creation/updates.

Local output platforms: The implementation MAY store a status row for change detection purposes. This is OPTIONAL; implementations that do not need content_hash-based change detection are not required to store rows for local output platforms.


Platform-specific Field Semantics

FieldAPI-based platformsLocal output platforms
platform_idRemote object identifier (MUST be set)NULL (no remote object)
remote_status“draft” or “published” (MUST be set)NULL (lifecycle not applicable)
urlPublic URL or draft URLLocal output path or NULL
content_hashContent hash for change detectionContent hash for change detection

PlatformStatus struct

#![allow(unused)]
fn main() {
pub struct PlatformStatus {
    pub published: bool,
    pub last_publish: Option<PublishResult>,
    pub content_hash: Option<String>,
    /// Remote lifecycle state (API-based platforms only).
    /// - Some("draft"): Content exists as draft
    /// - Some("published"): Content exists as published
    /// - None: Local output platform (lifecycle not applicable)
    pub remote_status: Option<String>,
}
}

Persistence Requirements

API-based Platforms

After each successful operation (create or update, draft or published), the implementation MUST store:

  • platform_id: The remote object identifier
  • remote_status: “draft” or “published” matching the actual remote state
  • url: The public URL (for published) or platform-specific draft URL (for draft)
  • content_hash: Hash of the content at time of operation

For DraftSupport::SeparateObjects, after a draft-to-publish transition:

  • The implementation MUST update platform_id to the new published object ID.
  • The implementation MUST update remote_status to “published”.
  • The implementation MUST update url to the public URL.

Local Output Platforms

If the implementation chooses to store a status row:

  • platform_id: MUST be NULL
  • remote_status: MUST be NULL
  • url: MAY be the local output path
  • content_hash: Hash of the content for change detection

Rationale: API-based platforms require state tracking for correct lifecycle transitions. The row persistence policy explicitly covers both draft and published operations to avoid ambiguity. Local output platforms have no remote state; row storage is optional and only useful for content_hash-based change detection.

Since: v0.1.0

[RFC-0005:C-PLATFORM-CAPABILITIES] Platform Capabilities (Informative)

This clause documents the draft support capabilities of each platform adapter.

API-based platforms:

PlatformDraftSupportNotes
HashnodeSeparateObjectsDraft and Post are different objects with different IDs
Dev.toStatusField { reversible: true }Same endpoint, toggle published field
GhostStatusField { reversible: true }Same endpoint, toggle status field
WordPressStatusField { reversible: true }Same endpoint, toggle status field
ConfluenceStatusField { reversible: true }Uses ?status=draft and publish via blueprint endpoint
NotionNoneNo draft concept in API

Local output platforms:

PlatformDraftSupportNotes
AstroNoneLocal file output
XiaohongshuNoneLocal file output
CopypasteNoneClipboard output

Platform-specific notes:

  • Hashnode: Requires publishDraft mutation for draft-to-publish. Draft IDs become invalid after publishing.
  • Confluence: Drafts accessed via ?status=draft query parameter. Publish via PUT /wiki/rest/api/content/blueprint/instance/{draftId}.
  • Notion: Pages are created directly; no draft workflow in the API.

Since: v0.1.0

[RFC-0005:C-PROJECT-ROOT] Project Root Definition (Normative)

The project root is the directory containing typub.toml.

All relative paths stored in the status database (status.db) MUST be resolved relative to the project root.

The system MUST reject paths that resolve outside the project root with a descriptive error.

Path normalization for storage: When persisting paths to the status database:

  1. Absolute paths MUST be converted to relative paths (relative to project root).
  2. If the path cannot be expressed as relative to the project root (i.e., the asset is outside the project tree), the system MUST reject the operation with an error.
  3. Relative paths MUST use forward slashes (/) as path separators, regardless of operating system.
  4. Relative paths MUST NOT begin with ./ or contain .. components after normalization.

Path resolution on read: When reading paths from the status database:

  1. Relative paths MUST be resolved against the project root to obtain absolute paths for file operations.
  2. The resolved path MUST be validated to exist within the project root.

Rationale: Storing relative paths enables project portability — the entire project directory (including typub.toml, content, and .typub/status.db) can be moved, synced, or shared across machines without breaking path references.

Since: v0.2.0

[RFC-0005:C-RENDER-PREAMBLE] Typst Render Preamble Resolution and Merge (Normative)

typub MUST support an optional preamble configuration key for Typst render wrapper injection.

The preamble key MUST resolve using the standard 5-level resolution order defined in RFC-0005:C-RESOLUTION-ORDER:

  1. meta.toml[platforms.<platform_id>].preamble
  2. meta.toml.preamble
  3. typub.toml[platforms.<platform_id>].preamble
  4. typub.toml.preamble
  5. Adapter-defined default (render config default)

Resolution semantics MUST use nullable presence (Option<String> equivalent):

  • Missing value at a layer MUST be treated as None and fall through.
  • Present value at a layer MUST be treated as Some(value) and stop fallback.

During Render stage wrapper assembly, implementations MUST preserve adapter-specific preamble behavior and append resolved user preamble after adapter preamble when present:

final_preamble = adapter_preamble + "\n\n" + user_preamble

If no user preamble is resolved, adapter preamble MUST remain unchanged.

Rationale:

  • Preserves existing platform-specific preamble contracts (for example Confluence and Xiaohongshu).
  • Keeps user customization aligned with the unified configuration resolution model.

Since: v0.2.2


Changelog

v0.2.2 (2026-02-23)

Add Typst preamble resolution and merge semantics

Added

  • Define optional preamble key with 5-layer resolution and adapter-preserving append behavior in Render stage

v0.2.1 (2026-02-22)

Terminology consistency for global config file

Added

  • Replace stale config.toml references with typub.toml in C-RESOLUTION-ORDER

v0.2.0 (2026-02-13)

Add C-PROJECT-ROOT clause and rename config.toml to typub.toml

v0.1.0 (2026-02-12)

Initial draft