sequenceDiagram
autonumber
participant C as Client
participant IDP as Mock IDP<br/>/mock-idp/*
participant A as Auth Router<br/>/auth/login
participant UAS as UserAuthService
participant GEO as GeoService
participant DB as PostgreSQL<br/>(app schema)
note over C,IDP: Step 1 — Obtain IDP token
C ->> IDP: POST /mock-idp/authenticate<br/>{ idp_user_id }
IDP -->> C: { access_token: JWT(sub=idp_user_id) }
note over C,DB: Step 2 — Consent-Aware Token Exchange
C ->> A: POST /auth/login<br/>{ idp_token, has_consented, invitation_id, age_class, ... }
A ->> IDP: GET /.well-known/jwks.json
IDP -->> A: { keys: [...] }
A ->> A: jwt.decode → extract sub claim
A ->> UAS: authenticate(context)
UAS ->> DB: identify_idp_user(idp, login_id)
alt Returning user
DB -->> UAS: User found
opt Returning user with consent
UAS ->> DB: Register consent (if has_consented)
end
opt Returning user with invitation
UAS ->> DB: process_invitation(user, invitation_id)
end
else New user
DB -->> UAS: No user → register_new_user()
UAS ->> DB: INSERT User (age_class, language, country)
UAS ->> GEO: resolve country from IP
GEO -->> UAS: country code (FR/US/...)
UAS ->> DB: INSERT IDPLogin
opt Has consented
UAS ->> DB: INSERT UserConsent
end
opt Has invitation
UAS ->> DB: process_invitation(user, invitation_id)
UAS ->> DB: INSERT ParentalConsent (implicit)
UAS ->> DB: INSERT CollectiveMembership
end
end
opt Parent user type
UAS ->> DB: Ensure parent has a collective<br/>(auto-create "Family")
end
UAS ->> UAS: issue_app_session(consent check)
alt enforce_consent_policy AND consent incomplete
UAS -->> A: bearer_token = None
else Consent OK or policy disabled
UAS ->> DB: UPSERT AppSession (scope=ONBOARDING|PENDING_CONSENT|FULL)
DB -->> UAS: AppSession
end
UAS ->> DB: COMMIT
opt Returning user (full consent)
UAS ->> DB: get_user_history()
DB -->> UAS: UserHistory
end
UAS -->> A: AuthResponse { bearer_token, profile, user_history }
A -->> C: { bearer_token, consent_verification, session_scope }
Onboarding & Contributor Tutorials
Last updated: 2026-05-08
This page is the starting point for new contributors. Work through the checklist below in order, then use the task-specific tutorials to make your first meaningful change.
New Contributor Checklist
Tutorial 1 — Run the Test Suite
Before making any change, verify that the existing tests pass.
cd api
pytestTo run a focused subset (faster):
pytest tests/i18n/ tests/agents/ -vExpected output: all tests pass. If any fail, check the Testing Guide for the fixture setup requirements (SQLite in-memory DB, GOOGLE_API_KEY dummy value).
Tutorial 2 — Add or Update a Translation
All user-facing strings live in two locations:
- Backend i18n:
api/i18n/as YAML files (prompts, questions, snippets, tools, UI messages). - Frontend i18n:
frontend/src/utils/i18n.tsas a TypeScript module (buttons, labels, modals, onboarding text).
2a — Add a context question (backend)
Context questions are in api/i18n/context_questions/<LANG>.yaml. Each file has a teenager and a parent section.
Example — adding a new question to FR.yaml:
# api/i18n/context_questions/FR.yaml
teenager:
# … existing questions …
platform: # new question key
question: Sur quelle plateforme cela s'est-il passé ?
dropdown_options:
- value: instagram
label: 📸 Instagram
- value: tiktok
label: 🎵 TikTok
- value: other
label: ❓ Autre
parent:
platform:
question: Sur quelle plateforme l'incident a-t-il eu lieu ?
dropdown_options:
- value: instagram
label: 📸 Instagram
- value: tiktok
label: 🎵 TikTok
- value: other
label: ❓ AutreRepeat the same key in every language file (EN.yaml, ES.yaml, IT.yaml, DE.yaml, PT.yaml). The i18n manager will fall back to French if a translation is missing, but a complete set is expected.
Verify with:
cd api && pytest tests/i18n/ -v2b — Update a frontend UI string
Frontend strings live in frontend/src/utils/i18n.ts as a nested object keyed by language. To add a new string:
// Add the English key in each language section
FR: {
consent: { authorizeButton: "Autoriser mon adolescent" },
// ... existing keys
},
EN: {
consent: { authorizeButton: "Authorize my teenager" },
// ...
},
ES: {
consent: { authorizeButton: "Autorizar a mi hijo" },
// ...
},Access the string in a component via:
import { getText } from '../utils/i18n';
const text = getText(language, 'consent.authorizeButton');Content categories include: consent, onboarding, settings, invitations, collectives, brandScreen, chatInput, header, and errors. Each category has corresponding keys across all 6 supported languages.
2c — Update a system prompt (backend)
System prompts live in api/i18n/prompts/<LANG>/ as YAML files named after the node (e.g., give_advice.yaml). Edit the relevant file directly — no Python restart is needed in development because prompts are reloaded from disk on each invocation (the two-layer cache is warm but the underlying files are always consulted for cold starts after a restart).
2d — Add a new language
- Create
api/i18n/context_questions/XX.yaml(copyFR.yamlas a template). - Create
api/i18n/ui_messages/XX.yaml. - Create
api/i18n/prompts/XX/and add one YAML file per node (copy and translate the FR versions). - Create
api/i18n/prompt_snippets/XX.yaml. - Create
api/i18n/tools/XX.yaml. - Add
"XX"toI18nSettings.supported_languagesinapi/config.py. - Add the language to
frontend/src/utils/i18n.ts. - Run
pytest tests/i18n/— the placeholder and completeness tests will catch missing keys.
The api/i18n/autotranslate/ tooling can produce a first draft automatically from French using the Gemini translation model.
Tutorial 3 — Node Prompt Management
This tutorial covers the full lifecycle of a node’s prompt: understanding the manifest format, modifying an existing prompt, adding new injected content, and creating a brand-new node.
How NodeEnv compiles a prompt
When a node is invoked, it calls NodeEnv.compile(node_id, state). The compiler:
- Loads
nodes/manifests/<node_id>.yaml. - Resolves the user’s
languageanduser_typefromstate. - Iterates the
system_prompt.partslist. Each entry is either:- A string (e.g.,
"identity","role") — resolved fromapi/i18n/prompts/<LANG>/<node>.yaml. - A dict — a dynamic part that is only included if its
conditionevaluates toTrueagainststate, and whose content is either injected fromstateor loaded from a prompt snippet.
- A string (e.g.,
- Appends any active tools from the
tools.catalog, filtered by their own optionalcondition. - Wraps every piece in XML-style tags (
<identity>…</identity>) for structural clarity. - Binds the resulting
SystemMessageto the LLM, attaching tools (bind_tools) or a structured output schema (with_structured_output).
Manifest format reference
# yaml-language-server: $schema=./node_manifest.schema.json
name: "my_node" # must match the node's key in graph.py
system_prompt:
parts:
# ── Static parts ──────────────────────────────────────────────────────
# Each string is a key that must exist in api/i18n/prompts/<LANG>/my_node.yaml
- identity
- role
# ── Dynamic injected parts ────────────────────────────────────────────
- id: situational_context # tag name in the compiled prompt
condition: "state.get('context_complete', False)"
inject: "state.context_data" # dotted path into state
formatter: dump_situation_context # function name in Registry.get_formatter()
# ── Snippet parts (load text from i18n prompt_snippets) ───────────────
- id: language_instruction
snippet: language_setting_prompt
format: # evaluated against state at runtime
language: "state.get('language', 'FR')"
# ── Conditional snippet ───────────────────────────────────────────────
- id: first_advice_hint
condition: "state.get('first_advice', False)"
snippet: "first_advice_prompt_extension"
tools:
preamble:
snippet: tool_preamble # loaded from i18n/prompt_snippets/<LANG>.yaml
catalog:
- research_educational_strategies # always active (no condition)
- tool: lookup_contacts_by_country # conditional
condition: "state.get('first_advice', False)"
answer_schema: MyNodeAnswer # Pydantic class name registered in RegistryValidate the manifest
The schema file nodes/manifests/node_manifest.schema.json lets your IDE flag invalid keys immediately. After adding a new node name to the schema you can regenerate it:
cd api
python agents/service1/nodes/manifests/refresh_manifest_schema.pyModifying an existing node’s prompt — step by step
Goal: add an extra reminder to give_advice only when the conversation is already in ongoing-support mode.
- Add a snippet key to every language’s
prompt_snippets/<LANG>.yaml:
# api/i18n/prompt_snippets/FR.yaml (and EN/ES/IT/DE/PT)
ongoing_support_reminder: |
Tu es en mode de soutien continu. Sois particulièrement attentif(ve) à
la cohérence avec le résumé de la conversation précédente.- Add the conditional part to
nodes/manifests/give_advice.yaml:
system_prompt:
parts:
# … existing parts …
- id: ongoing_support_reminder
condition: "state.get('ongoing_support_mode', False)"
snippet: "ongoing_support_reminder"Restart the backend (cold start re-reads the manifest). No Python changes required.
Test by sending a message while
ongoing_support_modeisTrueand checking Langfuse for the new<ongoing_support_reminder>tag in the trace.
Adding a new node — step by step
- Create the node function in
api/agents/service1/nodes/my_node.py:
import logging
from agents.service1.core.state import Service1State
from agents.service1.utils.node_env import NodeEnv
logger = logging.getLogger("chatbot.agent")
async def my_node(state: Service1State, store=None) -> dict:
prompt, llm = NodeEnv.compile("my_node", state)
response = await llm.ainvoke([prompt] + state["messages"])
return {"action": response.action, "messages": [response]}Create the manifest
nodes/manifests/my_node.yaml(see format reference above). Validate it against the schema.Register any new response schema in
api/agents/service1/core/registry.py:
from agents.service1.nodes.schemas import MyNodeAnswer
Registry.register_schema("MyNodeAnswer", MyNodeAnswer)- Wire the node into the graph (
graph.py):
from agents.service1.nodes.my_node import my_node
graph.add_node("my_node", my_node)
graph.add_edge("agent1", "my_node") # or use add_conditional_edgesAdd i18n prompts for the new node in
api/i18n/prompts/<LANG>/my_node.yamlfor every supported language.Write a sociable test in
tests/agents/test_nodes_injection.py(see Testing Guide).
Tutorial 4 — Extending the Database Schema
This tutorial walks through adding a new column to an existing table and creating an entirely new table, both with Alembic migrations.
How the schema is organised
All ORM models for the app schema live in api/database/models/__init__.py. The Base class attaches all these models to the app schema:
class Base(DeclarativeBase):
metadata = MetaData(schema=os.getenv("DB_SCHEMA") or Schema.APP.value)Enum types used in column definitions are in api/database/enums.py. The Alembic env.py uses a monkeypatched Enum.__init__ that forces inherit_schema=True on every enum, which causes PostgreSQL to create the ENUM type in the schema of each table that references it (rather than always in public).
Adding a column to an existing table
Goal: add a preferred_monster column to the User model.
- Update the ORM model in
api/database/models/__init__.py:
class User(Base):
# … existing columns …
preferred_monster = Column(String(100), nullable=True)- Generate the migration using Alembic’s autogenerate:
cd api
DATABASE_URL="postgresql+psycopg://user:pass@host/db" \
alembic revision --autogenerate -m "add preferred_monster to users"- Review the generated file in
api/migrations/versions/. Autogenerate is a good starting point but should always be inspected — check that theschema='app'argument is present on every DDL operation:
def upgrade() -> None:
op.add_column(
'users',
sa.Column('preferred_monster', sa.String(length=100), nullable=True),
schema='app' # ← required for multi-schema setups
)
def downgrade() -> None:
op.drop_column('users', 'preferred_monster', schema='app')- Apply the migration:
alembic upgrade head- Update any related Pydantic schemas in
api/schemas/app_data.pyif the new column should appear in API responses.
Adding a new table
Goal: create a UserPreference table that stores key/value preferences per user.
- Add a new model to
api/database/models/__init__.py:
class UserPreference(Base):
__tablename__ = "user_preferences"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
key = Column(String(100), nullable=False)
value = Column(Text, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
user = relationship("User", back_populates="preferences", lazy="joined")- Add the reverse relationship on
User:
class User(Base):
# … existing relationships …
preferences = relationship(
"UserPreference", back_populates="user",
cascade="all, delete-orphan", lazy="selectin",
)Generate and review the migration (same steps as above).
Add a new repository in
api/services/app_data/repositories/following the pattern of existing ones (UserRepository, etc.) and register it onAppDataService:
# In AppDataService.__init__:
self.preferences = UserPreferenceRepository()Adding a new enum value
If you need to extend an existing enum (e.g., add a new IDProvider):
- Add the value to
api/database/enums.py:
class IDProvider(enum.Enum):
MOCK_IDP = 'mock-idp'
GOOGLE = 'google' # new- Alembic cannot autogenerate enum alterations — write the migration manually:
from alembic import op
def upgrade() -> None:
op.execute("ALTER TYPE app.idprovider ADD VALUE IF NOT EXISTS 'google'")
def downgrade() -> None:
pass # PostgreSQL does not support removing enum values without recreating the typeRunning migrations in development
cd api
# Apply all pending migrations
alembic upgrade head
# Check current revision
alembic current
# Roll back one step
alembic downgrade -1
# Show migration history
alembic history --verboseTutorial 5 — Understanding and Extending the Auth Flow
How authentication works end-to-end
The auth system is a consent-aware two-step token exchange pattern with scope-based session issuance:
Key changes from the previous version:
- Consent is now first-class: The
has_consentedflag is sent during login. If theenforce_consent_policyflag is active, no bearer token is issued without full consent. - Invitation processing: If an
invitation_idis provided, theUserAuthServiceprocesses it — granting implicitParentalConsentand adding the user to the parent’sCollective. - Geo-IP country resolution: During new user registration, the client IP is resolved to a country code and stored on the
Usermodel. - Auto-collective creation: Parent users automatically get a “Family” collective with
OWNERrole on first login. - Session scope assignment: The
AppSession.scopeis determined by consent status —ONBOARDING,PENDING_CONSENT, orFULL. This drives the frontend UI routing. - Authorization requests: Teenagers can request parental authorization via
POST /auth/request-authorization, generating a voucher that parents fulfill via/authorize-child?token=....
Step 1 — Mock IDP (POST /api/v1/mock-idp/authenticate)
The client sends an idp_user_id string. The mock IDP wraps it in a JWT (signed with "encryption_key") and returns { access_token }. In production this step is replaced by a real OAuth 2.0 / OIDC provider.
Step 2 — Consent-aware token exchange (POST /api/v1/auth/login)
The backend:
- Fetches the IDP’s public key and decodes the JWT to extract
sub. - Calls
UserAuthService.authenticate()which:- Identifies the user via
(id_provider, login_id). - For returning users: registers any new consent (
has_consented), processes anyinvitation_id, and updateslast_seen_at. - For new users: creates
User+IDPLoginrecords. If the client supports Geo-IP, resolves country from client IP. Registers initial consent, processes invitations. - For parent users: auto-creates a “Family”
CollectivewithOWNERmembership. - Issues session: evaluates consent status against the
enforce_consent_policyflag. AssignsAppSession.scope(ONBOARDING,PENDING_CONSENT, orFULL). If consent is enforced and incomplete, returnsbearer_token = null.
- Identifies the user via
- Returns
AuthResponsecontaining the optionalbearer_token, aprofile(withconsent_verificationstatus), anduser_history(only for consented returning users).
Key data models
IDPLogin (composite PK: idp + login_id)
└── user_id ──► User
├── consent ──► UserConsent
├── parental_consent ──► ParentalConsent
├── app_sessions ──► AppSession (scope: ONBOARDING/PENDING_CONSENT/FULL)
├── conversations ──► Conversation
└── memberships ──► CollectiveMembership ──► Collective
├── invitations ──► Invitation
└── members ──► CollectiveMembership
IDPLogin decouples the external identity from the internal user profile. One user can have multiple IDP logins (e.g., both a mock IDP and a future Google IDP). ParentalConsent links a parent User to a teenager User directly (no intermediary), granting implicit parental authorization.
SessionScope (ONBOARDING → PENDING_CONSENT → FULL) forms a state machine that the frontend uses to decide what UI to show. A UserConsent row represents TOS acceptance; a ParentalConsent row represents a parent’s explicit authorization.
Testing the auth flow locally
# Step 1 — get a mock IDP token
curl -s -X POST http://localhost:8000/api/v1/mock-idp/authenticate \
-H "Content-Type: application/json" \
-d '{"idp_user_id": "test-user-42"}' | python3 -m json.tool
# Step 2 — exchange for an app session (with consent)
curl -s -X POST http://localhost:8000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"idp_token": "<token-from-step-1>",
"id_provider": "mock-idp",
"user_type": "parent",
"language": "FR",
"origin": "web_react",
"has_consented": true,
"invitation_id": null,
"capabilities": {"supports_sse": true}
}' | python3 -m json.toolA successful response returns bearer_token (use as Authorization: Bearer <token>), the profile object (including consent_verification), and — for returning consent-covered users — user_history with a list of past conversations.
Adding a new Identity Provider
- Add the enum value to
IDProviderinapi/database/enums.py:
class IDProvider(enum.Enum):
MOCK_IDP = 'mock-idp'
GOOGLE = 'google' # newWrite a migration to extend the PostgreSQL enum (see Tutorial 4 — Adding a new enum value).
Add public-key retrieval logic in
api/routers/auth.py. Replace the call tomock_idp_pk_urlwith a lookup that selects the correct JWKS endpoint based onauth_idp_request.id_provider:
IDP_JWKS_URLS = {
IDProvider.MOCK_IDP: f"http://localhost:{settings.server.port}/api/v1/mock-idp/.well-known/jwks.json",
IDProvider.GOOGLE: "https://www.googleapis.com/oauth2/v3/certs",
}
async def get_public_key(id_provider: IDProvider) -> str:
url = IDP_JWKS_URLS[id_provider]
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
data = await resp.json()
return data["keys"][0]["k"]Update the
AuthIDPRequestschema andlogin_with_idphandler to passid_providerthrough toget_public_key.Write auth tests in
tests/auth/test_auth_flow.pythat cover the new provider path.
Tutorial 6 — Monkeypatches Reference
The codebase applies four runtime patches to SQLAlchemy and the test engine. They fall into two groups with distinct concerns, documented in the pages where they are most useful:
| Patch | Lives in | Why | Full docs |
|---|---|---|---|
Enum inherit_schema |
run.py, migrations/env.py |
PostgreSQL ENUM types are created in the schema of each table that references them, not public |
Data Layer → Database Migrations |
Document.__hash__ |
services/rag/__init__.py |
LangChain Document is unhashable by default; the RAG cache needs documents as dict/set keys |
RAG Pipeline → Known Workaround |
create_engine kwargs strip |
tests/conftest.py |
Remove PG-only pool args that break SQLite | Testing → database.py fixtures |
create_async_engine URL redirect |
tests/conftest.py |
Force all async engines to the in-memory test DB | Testing → database.py fixtures |
Base.metadata.schema reset |
tests/fixtures/database.py |
Remove app. schema prefix incompatible with SQLite |
Testing → database.py fixtures |
All patches are applied at module import time with no teardown, which is safe because each process has exactly one schema target (production) or one database target (tests).
Known duplication: the
Enum inherit_schemapatch is copy-pasted identically intorun.pyandmigrations/env.py. A future clean-up would centralise it inapi/core/patches.py.
Tutorial 7 — Add Documents to the RAG Knowledge Base
Place the new PDF(s) in the correct sub-directory:
rag/raw_files/youth/for teenager contentrag/raw_files/adult/for parent content
Index the new documents:
cd rag # Index teenager documents only python index_documents.py --variant teenager # Index parent documents only python index_documents.py --variant parent # Index both python index_documents.py --variant allVerify retrieval by sending a chat message that should trigger the relevant content and checking the Langfuse trace for the
research_educational_strategiestool call.
Tutorial 8 — Add or Toggle a Feature Flag
Toggle an existing flag in development
Flags are seeded into the database on startup. Call GET /api/v1/features to inspect current resolved values.
Add a new feature flag
- Seed it — add a new
FeatureFlagentry inFeatureFlagService._seed_default_flags():
FeatureFlag(
name="my_new_flag",
description="Enable the new widget",
is_enabled=False,
environment="all",
visibility="frontend", # "frontend", "backend", or "all"
variant="all", # "teenager", "parent", or "all"
)- Use it on the backend (if
visibilityincludes"backend"):
from core.dependencies import get_feature_flag_service
svc = get_feature_flag_service()
enabled = await svc.get_flag_value("my_new_flag", default=False)- Use it on the frontend (if
visibilityincludes"frontend"):
import { useBooleanFlagValue } from '@openfeature/react-sdk';
const enabled = useBooleanFlagValue('my_new_flag', false);- Document it — add a row to the feature-flag table in Backend API.
Tutorial 12 — Understand and Extend the Consent System
This tutorial explains how the consent pipeline works and how to add new consent-aware behaviour.
Consent state machine
Every user goes through a consent pipeline modeled as three session scopes:
ONBOARDING ──► PENDING_CONSENT ──► FULL
ONBOARDING: The user has not yet accepted the Terms of Service. The frontend shows theSettingsPanelwith the consent toggle unchecked.PENDING_CONSENT: TOS accepted, but the user is a minor (needs_parental_consent = true) and noParentalConsentrecord exists linking them to a parent.FULL: All consent requirements satisfied. Unrestricted access to chat.
The scope is assigned by UserAuthService.issue_app_session() and stored on AppSession.scope. The frontend reads this scope from the auth response and routes the UI accordingly.
Two consent enforcement mechanisms
| Flag | Layer | Behavior when true |
|---|---|---|
enforce_consent_policy |
Backend | UserAuthService returns bearer_token = null for users without consent. The client cannot make any authenticated requests. |
enforce_consent_gate |
Frontend | ConsentGate component renders instead of the chat UI. The user can still authenticate, but cannot chat. |
In development, both flags default to false for easier testing.
Adding consent to a new endpoint
To make an existing endpoint consent-aware:
Check the session scope via the
AppSessiondependency: ```python from core.dependencies import get_current_session@router.get(“/protected”) async def protected_route( curr_app_session: AppSession = Depends(get_current_session), ): if curr_app_session.scope != SessionScope.FULL: raise HTTPException(403, “Consent required”) … ```
For frontend routes, check the
consent_verificationblock fromAuthContext:typescript const { consentVerification, sessionScope } = useAuth(); if (sessionScope !== 'FULL') { return <ConsentGate />; }
Creating an invitation programmatically
# Create an invitation for a teenager
curl -X POST http://localhost:8000/api/v1/collectives/{collective_id}/invitations \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"age_class": "teenager 15-17",
"language": "FR",
"label": "Mon enfant"
}'
# The invite_url can be shared via QR code or link
# When the teenager logs in with this invitation_id, they get:
# - UserConsent (implicit TOS)
# - ParentalConsent (implicit authorization)
# - CollectiveMembership (added to parent's collective)Tutorial 9 — Write a Test
All tests live under api/tests/. Use the modular fixtures from tests/fixtures/ — do not redefine infrastructure in individual test files.
Minimal example — testing a service method:
# tests/chat/test_my_feature.py
import pytest
from tests.fixtures.services import app_data_service # imported via conftest
@pytest.mark.asyncio
async def test_create_user(app_data_service, db_session):
user = await app_data_service.create_user(
user_type="teenager", language="FR"
)
assert user.id is not None
assert user.language == "FR"See Testing Guide for the full fixture reference and test-category conventions.
Tutorial 10 — Deploy to Staging
Once your change is reviewed and merged to dev, the CI pipeline deploys automatically. For manual hot-stage deployments before merging:
# From the repo root — requires GCP credentials
python deployment/deploy.py --variant teenager --suffix my-feature
# Frontend only
python deployment/deploy.py --frontend-only --variant teenager --suffix my-feature
# Both variants in parallel
python deployment/deploy.py --suffix my-featureSee Deployment for the full option reference.
Tutorial 11 — Contribute to the Documentation
Documentation uses Quarto .qmd files (a superset of Markdown). No local Quarto installation is needed — VS Code and JupyterLab both have Quarto extensions that render previews.
- Edit or create a
.qmdfile indoc/. - If creating a new page, register it in
doc/_quarto.yaml:- Add the filename to the
project.renderlist. - Add a sidebar entry under the appropriate section in
website.sidebar.contents.
- Add the filename to the
- Open a pull request to
dev. Thebuild_quarto_docs.yamlworkflow will render the site and deploy it automatically on merge.
See Extending the Documentation for the Quarto syntax reference.