From dac499c48337f6496a7b5943f421ec18070e6e31 Mon Sep 17 00:00:00 2001 From: egeozin Date: Mon, 5 Oct 2026 13:01:28 -0700 Subject: [PATCH] Show a draft's figures, embeds and videos, and let drafts use them Page bundles, part 1b (the experience), on top of #311's contract. - Reader: render_markdown takes the article's assets folder. Figures render without a URL and load with the member's session (SVG as an inert data: image, as in Files); tin-embed blocks run in an allow-scripts-only sandboxed frame with an injected policy that blocks the network; tin-video blocks play YouTube (privacy mode), Vimeo, Loom or Mux, a video file loads only on play, and anything else stays a link; `> [!NOTE]`-style quotes become callouts. A note under the title names any file Tin left out and why. - The run and Files document endpoints return the kept assets; the app passes a session-bound loader to the viewer. - Decisions: the explanation says what the draft carries ("With 2 figures and 1 interactive piece."). - Drafts: content.public_article 1.9.0 and content.generate 1.10.0 (articles only) declare an assets folder and use a new page-figures skill: figures only where they show what words can't, Mermaid for diagrams, self-contained embeds with no network, existing videos only, and kept figures from an existing page. Co-Authored-By: Claude Opus 5.5 (1M context) --- codex_procedures/content.generate/PROMPT.md | 4 +- .../skills/page-figures/SKILL.md | 77 ++++++++ .../content.public_article/PROMPT.md | 8 +- .../skills/page-figures/SKILL.md | 77 ++++++++ docs/content-review.md | 6 + package.json | 2 +- src/tin_lite/activities.py | 9 +- src/tin_lite/api.py | 73 ++++++- src/tin_lite/catalog.py | 9 +- src/tin_lite/documents.py | 78 +++++++- src/tin_lite/page_assets.py | 23 +++ src/tin_lite/static/app.css | 91 +++++++++ src/tin_lite/static/app.js | 10 + src/tin_lite/static/markdown-viewer.js | 183 ++++++++++++++++-- tests/js/procedure-output.cjs | 2 +- tests/test_article_search_structure.py | 4 +- tests/test_content_draft.py | 2 + tests/test_one_content_generate.py | 5 +- tests/test_page_assets.py | 68 ++++++- tests/test_writing_style.py | 2 +- web/page-bundles.browser.test.js | 133 +++++++++++++ 21 files changed, 825 insertions(+), 41 deletions(-) create mode 100644 codex_procedures/content.generate/skills/page-figures/SKILL.md create mode 100644 codex_procedures/content.public_article/skills/page-figures/SKILL.md create mode 100644 web/page-bundles.browser.test.js diff --git a/codex_procedures/content.generate/PROMPT.md b/codex_procedures/content.generate/PROMPT.md index 7c03e281..b0a8124c 100644 --- a/codex_procedures/content.generate/PROMPT.md +++ b/codex_procedures/content.generate/PROMPT.md @@ -71,7 +71,9 @@ Write exactly two UTF-8 Markdown files at the declared `output.path` and `output.companion_path`. For `draft`, the primary file is public copy only: start with a `# Title`, then the complete article/page draft with factual sources beside the supported claims. Shape it with the search-and-answer-engines skill: answer first, question headings, a table -when comparing options, an FAQ where it fits and `## Sources` last. +when comparing options, an FAQ where it fits and `## Sources` last. For an article, use the +page-figures skill for any figure, diagram, interactive piece, video or callout it needs; an +answer page or a page refresh has none. No Tin frontmatter, checklists, generation notes or approval commentary belongs in this file. For any no-draft outcome, write a short assessment instead of an article. Its exact format is `# Content assessment`, a blank line, the exact `rationale` string from the judgment below, diff --git a/codex_procedures/content.generate/skills/page-figures/SKILL.md b/codex_procedures/content.generate/skills/page-figures/SKILL.md new file mode 100644 index 00000000..ffd62c45 --- /dev/null +++ b/codex_procedures/content.generate/skills/page-figures/SKILL.md @@ -0,0 +1,77 @@ +--- +name: page-figures +description: Add figures, diagrams, interactive pieces, videos and callouts to an article draft only where they show what the words can't, as files in its assets folder that the founder approves with the article. +--- + +# Figures, diagrams, interactive pieces and videos + +Add one only where it shows something the words can't carry as well: a process, a comparison, +a structure, a measured result. Most articles need none, one or two. Never decorate. + +Your output instructions name the article's assets folder. Write each file directly in it and +refer to it from the article with a path that starts `.//`. Tin keeps only the files +the article refers to, and the founder approves them with the words. + +## Diagram + +A `mermaid` block for a flow, a sequence, a hierarchy or a timeline. Tin draws it in the +project's brand colours, so don't set colours or styles in it. Keep it to the few nodes the point +needs. + +## Figure + +An SVG file in the assets folder: + +```markdown +![What the figure shows](.//.svg "What the reader should notice") +``` + +The alt text says what the figure shows; the caption says what to notice. Draw it plainly, with +real labels and the article's own numbers. The SVG must have no script, no event handlers, no +`foreignObject` and no links or images from elsewhere; Tin leaves out an SVG that has any. + +## Interactive piece + +One self-contained HTML file in the assets folder, only when interaction teaches something a still +figure can't, such as a simulation or a calculator: + +````markdown +```tin-embed +src: .//.html +height: 420 +title: What the reader can do with it +``` +```` + +Inline its CSS and JavaScript. It runs with no network access: no requests, no outside fonts, +libraries or images. Keep it small, give it a sensible default state, and make it work with a +mouse, a keyboard and a touch screen. + +## Video + +A video that already exists at a public address, on the founder's site or on YouTube, Vimeo, Loom +or Mux: + +````markdown +```tin-video +url: https://www.youtube.com/watch?v=... +title: What it shows +``` +```` + +Never invent a video or an address. A long video file is linked, never copied into the folder. + +## Callout + +`> [!NOTE]`, `> [!TIP]` or `> [!WARNING]` on the first line of a quote, for a short aside the +reader shouldn't miss. Use one or two at most. + +## Existing pages + +When the brief points to an existing page that has figures and you can read its source, keep +them: copy an inline SVG into the folder as its own file, or rebuild an interactive piece as an +embed with its script and data. Don't describe a figure in words instead of keeping it. If you +can't carry one over, say which and why in Generation notes. + +List every figure, diagram, interactive piece and video in Generation notes with one line on what +each shows. diff --git a/codex_procedures/content.public_article/PROMPT.md b/codex_procedures/content.public_article/PROMPT.md index 45301e77..2bf16356 100644 --- a/codex_procedures/content.public_article/PROMPT.md +++ b/codex_procedures/content.public_article/PROMPT.md @@ -22,10 +22,12 @@ Plan before drafting, but publish only the finished article in the declared Mark article should open in the reader's world, land one salient idea, explain the mechanism, use concrete evidence, acknowledge material assumptions and limitations, and leave the reader with a useful contribution. Shape it with the search-and-answer-engines skill: its search-listing frontmatter -comes first, then the `# ` title and an answer-first opening. Apply the public-article-edit pass -before finishing. +comes first, then the `# ` title and an answer-first opening. Use the page-figures skill for any +figure, diagram, interactive piece, video or callout the article needs. Apply the public-article-edit +pass before finishing. -This is a reviewable draft, not a publishing action. Do not change another project file, publish to +This is a reviewable draft, not a publishing action. Do not change another project file (the +article's assets folder is part of the draft), publish to a website, send the article, contact anyone, or ask an interactive question. Put unresolved choices or missing evidence into the separate Generation notes document, never the public copy. diff --git a/codex_procedures/content.public_article/skills/page-figures/SKILL.md b/codex_procedures/content.public_article/skills/page-figures/SKILL.md new file mode 100644 index 00000000..ffd62c45 --- /dev/null +++ b/codex_procedures/content.public_article/skills/page-figures/SKILL.md @@ -0,0 +1,77 @@ +--- +name: page-figures +description: Add figures, diagrams, interactive pieces, videos and callouts to an article draft only where they show what the words can't, as files in its assets folder that the founder approves with the article. +--- + +# Figures, diagrams, interactive pieces and videos + +Add one only where it shows something the words can't carry as well: a process, a comparison, +a structure, a measured result. Most articles need none, one or two. Never decorate. + +Your output instructions name the article's assets folder. Write each file directly in it and +refer to it from the article with a path that starts `.//`. Tin keeps only the files +the article refers to, and the founder approves them with the words. + +## Diagram + +A `mermaid` block for a flow, a sequence, a hierarchy or a timeline. Tin draws it in the +project's brand colours, so don't set colours or styles in it. Keep it to the few nodes the point +needs. + +## Figure + +An SVG file in the assets folder: + +```markdown +![What the figure shows](.//.svg "What the reader should notice") +``` + +The alt text says what the figure shows; the caption says what to notice. Draw it plainly, with +real labels and the article's own numbers. The SVG must have no script, no event handlers, no +`foreignObject` and no links or images from elsewhere; Tin leaves out an SVG that has any. + +## Interactive piece + +One self-contained HTML file in the assets folder, only when interaction teaches something a still +figure can't, such as a simulation or a calculator: + +````markdown +```tin-embed +src: .//.html +height: 420 +title: What the reader can do with it +``` +```` + +Inline its CSS and JavaScript. It runs with no network access: no requests, no outside fonts, +libraries or images. Keep it small, give it a sensible default state, and make it work with a +mouse, a keyboard and a touch screen. + +## Video + +A video that already exists at a public address, on the founder's site or on YouTube, Vimeo, Loom +or Mux: + +````markdown +```tin-video +url: https://www.youtube.com/watch?v=... +title: What it shows +``` +```` + +Never invent a video or an address. A long video file is linked, never copied into the folder. + +## Callout + +`> [!NOTE]`, `> [!TIP]` or `> [!WARNING]` on the first line of a quote, for a short aside the +reader shouldn't miss. Use one or two at most. + +## Existing pages + +When the brief points to an existing page that has figures and you can read its source, keep +them: copy an inline SVG into the folder as its own file, or rebuild an interactive piece as an +embed with its script and data. Don't describe a figure in words instead of keeping it. If you +can't carry one over, say which and why in Generation notes. + +List every figure, diagram, interactive piece and video in Generation notes with one line on what +each shows. diff --git a/docs/content-review.md b/docs/content-review.md index 8d8b89ff..1d9d5941 100644 --- a/docs/content-review.md +++ b/docs/content-review.md @@ -15,6 +15,12 @@ the next roadmap item. separate artifact and viewer action, never text or a link embedded in public copy. - Approval is bound to the current reviewed revision. An old approval cannot approve a newer draft. Only the final approved copy is eligible for configured delivery. +- An article (content.public_article 1.9.0, content.generate 1.10.0 articles) may carry + figures, diagrams, interactive pieces, videos and callouts. The reader shows them as + they'll ship: SVG figures as images, embeds in a sandboxed frame with no network or + access to Tin, videos from YouTube, Vimeo, Loom or Mux (a video file loads only on + play), and a note under the title for any file Tin left out. The Decisions card says + what the draft carries, for example "With 2 figures and 1 interactive piece." - A failed revision needs explicit retry. Feedback is not discarded by generic retry, and a stopped revision closes its waiting review ancestry. - Discard turns down anything waiting in Decisions: the run ends as declined, nothing it diff --git a/package.json b/package.json index b1ed8d0c..d2332162 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test:browser": "node --test web/raw-file-security.browser.test.js web/fonts.browser.test.js web/diagram-renderer.browser.test.js web/diagram-viewport.browser.test.js scripts/check_diagram.test.mjs web/json-viewer.test.js web/json-viewer.browser.test.js web/output-comparison.browser.test.js web/content-plan.browser.test.js web/content-plan-page.browser.test.js web/billing.browser.test.js web/run-payment-card.browser.test.js web/style-capture.browser.test.js web/content-draft.browser.test.js web/content-delivery.browser.test.js web/workflow-review.browser.test.js web/auth.browser.test.js web/mcp-consent.browser.test.js web/app-domain.browser.test.js web/lock-page.browser.test.js web/project-menu.browser.test.js web/chat.browser.test.js web/project-connections.browser.test.js web/code-workflow.browser.test.js web/page-url.browser.test.js web/files-page.browser.test.js web/narrow-layout.browser.test.js web/decisions.browser.test.js web/website-changes.browser.test.js web/integration-resource.browser.test.js web/x-posts.browser.test.js web/x-feedback.browser.test.js web/x-reader-page.browser.test.js web/x-integration.browser.test.js web/x-workflow-setup.browser.test.js web/monthly-schedule.browser.test.js", + "test:browser": "node --test web/raw-file-security.browser.test.js web/fonts.browser.test.js web/diagram-renderer.browser.test.js web/diagram-viewport.browser.test.js scripts/check_diagram.test.mjs web/json-viewer.test.js web/json-viewer.browser.test.js web/output-comparison.browser.test.js web/content-plan.browser.test.js web/content-plan-page.browser.test.js web/billing.browser.test.js web/run-payment-card.browser.test.js web/style-capture.browser.test.js web/content-draft.browser.test.js web/content-delivery.browser.test.js web/workflow-review.browser.test.js web/auth.browser.test.js web/mcp-consent.browser.test.js web/app-domain.browser.test.js web/lock-page.browser.test.js web/project-menu.browser.test.js web/chat.browser.test.js web/project-connections.browser.test.js web/code-workflow.browser.test.js web/page-url.browser.test.js web/files-page.browser.test.js web/narrow-layout.browser.test.js web/decisions.browser.test.js web/website-changes.browser.test.js web/integration-resource.browser.test.js web/x-posts.browser.test.js web/x-feedback.browser.test.js web/x-reader-page.browser.test.js web/x-integration.browser.test.js web/x-workflow-setup.browser.test.js web/monthly-schedule.browser.test.js web/page-bundles.browser.test.js", "test:x-browser": "node --test web/x-posts.browser.test.js web/x-feedback.browser.test.js web/x-reader-page.browser.test.js web/x-integration.browser.test.js web/x-workflow-setup.browser.test.js web/monthly-schedule.browser.test.js", "test:files-browser": "node --test web/files-page.browser.test.js", "test:narrow-layout-browser": "node --test web/narrow-layout.browser.test.js", diff --git a/src/tin_lite/activities.py b/src/tin_lite/activities.py index 7a9d4348..7a247130 100644 --- a/src/tin_lite/activities.py +++ b/src/tin_lite/activities.py @@ -4447,6 +4447,13 @@ async def request_codex_procedure_review(self, run_id_text: str) -> bool: repo_id=project.state_repo_id, commit_sha=sha, path=path ) artifact_title, lede = display_title(raw), review_line(raw) + from tin_lite.page_assets import attachments_line + + attachments = attachments_line( + canonical.result.get("checkpoint"), raw.decode("utf-8", errors="replace") + ) + else: + attachments = None destination = ( f"Approval opens an unmerged GitHub PR in {delivery['repository']}" + ("." if delivery.get("system_run_id") else f" at {delivery['path']}.") @@ -4469,7 +4476,7 @@ async def request_codex_procedure_review(self, run_id_text: str) -> bool: artifact_path=path, artifact_title=artifact_title, summary=summary, - explanation=" ".join(part for part in (lede, destination) if part), + explanation=" ".join(part for part in (lede, attachments, destination) if part), ) if required: from tin_lite.organic_content import project_review_progress diff --git a/src/tin_lite/api.py b/src/tin_lite/api.py index a08c67b2..de84b76a 100644 --- a/src/tin_lite/api.py +++ b/src/tin_lite/api.py @@ -1121,6 +1121,13 @@ class MarkdownHeadingView(BaseModel): title: str +class MarkdownAssetView(BaseModel): + """A figure or embed the reader loads from the project at the document's revision.""" + + path: str + media_type: str + + class MarkdownDocumentView(BaseModel): markdown: str html: str @@ -1135,6 +1142,57 @@ class MarkdownDocumentView(BaseModel): size_bytes: int | None = None related_documents: list[dict[str, str]] = Field(default_factory=list) sha256: str | None = None + assets: list[MarkdownAssetView] = Field(default_factory=list) + # Files the draft referred to that Tin left out, each with its reason. + asset_notes: list[str] = Field(default_factory=list) + + +async def _run_document_assets(database, run, path, source): + """A draft's kept figures and embeds, from its published checkpoint, and what was left out.""" + from tin_lite import page_assets + + if source != "canonical" or not path.endswith(".md") or run.executor != "codex.procedure": + return [], [], None + published = await database.get_effect(f"{run.id}:procedure_canonical_commit") + checkpoint = ( + (published.result or {}).get("checkpoint") + if published is not None and published.status == "completed" + else None + ) + persisted = await database.get_effect(f"{run.id}:procedure_artifact_persist") + dropped = ((persisted.result or {}) if persisted is not None else {}).get("dropped_assets") + notes = [ + f"{item['path'].rsplit('/', 1)[-1]} was left out: {item['reason']}." + for item in dropped or [] + ] + assets = [ + MarkdownAssetView(path=item["artifact_path"], media_type=item["media_type"]) + for item in (checkpoint or {}).get("assets") or [] + ] + return assets, notes, page_assets.folder(path) if assets or notes else None + + +async def _file_document_assets(storage, project, path, revision, markdown): + """The figures and embeds a Markdown file in the project refers to that exist there.""" + from tin_lite import page_assets + + if not path.endswith(".md"): + return [], None + referenced = page_assets.referenced(markdown, path) + if not referenced: + return [], None + try: + present = set( + await storage.list_canonical_files_at(repo_id=project.state_repo_id, revision=revision) + ) + except LookupError: + return [], None + assets = [ + MarkdownAssetView(path=item, media_type=page_assets.media_type(item)) + for item in referenced + if item in present + ] + return assets, page_assets.folder(path) class ProjectFileView(BaseModel): @@ -2721,7 +2779,7 @@ async def get_project_file_document( status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="project file is not Markdown", ) - _, content = await _read_project_file( + project, content = await _read_project_file( project_id=project_id, path=path, revision=revision, @@ -2735,7 +2793,10 @@ async def get_project_file_document( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Markdown project file is not UTF-8", ) from exc - document = render_markdown(markdown) + assets, folder = await _file_document_assets( + request.app.state.runtime.storage, project, path, revision, markdown + ) + document = render_markdown(markdown, asset_folder=folder) source_query = f"path={quote(path, safe='')}&revision={revision}" response.headers["X-Tin-File-Source"] = "code.storage" response.headers["X-Tin-File-Revision"] = revision @@ -2753,6 +2814,7 @@ async def get_project_file_document( path=path, revision=revision, size_bytes=len(content), + assets=assets, ) @@ -4469,7 +4531,10 @@ async def get_artifact_document( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Markdown artifact is not UTF-8", ) from exc - document = render_markdown(markdown) + assets, asset_notes, folder = await _run_document_assets( + request.app.state.runtime.database, run, output.path, source + ) + document = render_markdown(markdown, asset_folder=folder) response.headers["X-Tin-Artifact-Source"] = "code.storage" response.headers["X-Tin-Output-Kind"] = source response.headers["X-Tin-Output-Revision"] = output.revision @@ -4491,6 +4556,8 @@ async def get_artifact_document( headings=[ MarkdownHeadingView(id=heading.id, title=heading.title) for heading in document.headings ], + assets=assets, + asset_notes=asset_notes, ) diff --git a/src/tin_lite/catalog.py b/src/tin_lite/catalog.py index 72ccbf17..124b6656 100644 --- a/src/tin_lite/catalog.py +++ b/src/tin_lite/catalog.py @@ -89,6 +89,7 @@ from tin_lite.model_providers import ModelCapability, ModelRoute, ProviderName from tin_lite.organic_audit import AUDIT_KEY, AUDIT_POLICY, MARKETS from tin_lite.organic_audit_ai import AI_CONTRACT, AI_SCHEMAS +from tin_lite.page_assets import AssetPolicy from tin_lite.procedure_documents import AGENT_REVISION from tin_lite.procedures import ( BROWSER_SANDBOX_PROFILE, @@ -852,7 +853,7 @@ def definition_and_files_with_wiki( "route you chose, and a refresh changes exactly the approved lines. The roadmap stays " "unchanged.", executor=CODEX_PROCEDURE_EXECUTOR, - version_label="1.9.0", + version_label="1.10.0", system=ORGANIC_TRAFFIC_SYSTEM, # A weekly occurrence drafts the next article in plan order and holds while an # earlier draft from the same program still waits for review. @@ -925,6 +926,8 @@ def definition_and_files_with_wiki( output_path_template=content_draft.PATH_TEMPLATE, output_validator=content_draft.EDITORIAL_VALIDATOR, output_max_bytes=80_000, + # An article's figures and embeds (page_assets), approved with its words. + output_assets=AssetPolicy(max_files=12, max_bytes=2_000_000), project_skills=( ProjectSkillDependency(name="writing-style", path=STYLE_PATH, required=False), ), @@ -1776,7 +1779,7 @@ def definition_and_files_with_wiki( "public article." ), executor=CODEX_PROCEDURE_EXECUTOR, - version_label="1.8.0", + version_label="1.9.0", system=ORGANIC_TRAFFIC_SYSTEM, prerequisites=( WorkflowPrerequisite( @@ -1849,6 +1852,8 @@ def definition_and_files_with_wiki( output_path_template=article_review.PATH_TEMPLATE, output_validator="public-article.v2", output_max_bytes=300_000, + # Its figures and embeds (page_assets), approved with its words. + output_assets=AssetPolicy(max_files=12, max_bytes=2_000_000), project_skills=( ProjectSkillDependency( name="writing-style", diff --git a/src/tin_lite/documents.py b/src/tin_lite/documents.py index 5f4dbd49..ae548372 100644 --- a/src/tin_lite/documents.py +++ b/src/tin_lite/documents.py @@ -34,13 +34,31 @@ class RenderedMarkdown: reading_minutes: int +_CALLOUT = re.compile(r"\s*

\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:
)?\n?") +_ASSET_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,80}\.(?:svg|html)") +_FIELD = re.compile(r"([a-z]+)[ \t]*:[ \t]*(.+)") + + class _TinHTMLRenderer(HTMLRenderer): - def __init__(self) -> None: + def __init__(self, asset_folder: str | None = None) -> None: # Raw HTML is handled explicitly below. This lets existing reports keep their harmless #
/ formatting while every other tag is shown as text instead of executed. super().__init__(escape=False) self.headings: list[MarkdownHeading] = [] self._slug_counts: dict[str, int] = {} + # An article's assets folder (page_assets): figures and embeds the reader loads itself. + self.asset_folder = asset_folder + + def _asset(self, url: str) -> str | None: + """The project path of a file in the article's assets folder, or None.""" + if not self.asset_folder: + return None + name = self.asset_folder.rsplit("/", 1)[-1] + relative = url.strip().removeprefix("./") + if not relative.startswith(f"{name}/"): + return None + file = relative[len(name) + 1 :] + return f"{self.asset_folder}/{file}" if _ASSET_NAME.fullmatch(file) else None def heading(self, text: str, level: int, **attrs: object) -> str: title = unescape(striptags(text)).strip() @@ -61,6 +79,15 @@ def link(self, text: str, url: str, title: str | None = None) -> str: def image(self, text: str, url: str, title: str | None = None) -> str: alt = striptags(text) filename = PurePosixPath(urlparse(url).path).name or "image" + if asset := self._asset(url): + # No src: the reader loads the file with the member's session, never by URL. + rendered = ( + f'" rendered = ( f'{safe_entity(alt)} str: language = "" if info: language = info.strip().split(None, 1)[0] + if language in {"tin-embed", "tin-video"} and self.asset_folder: + fields = { + match[1]: match[2].strip() + for line in code.splitlines() + if (match := _FIELD.fullmatch(line.strip())) + } + title = safe_entity(fields.get("title", "")) + if language == "tin-embed" and (asset := self._asset(fields.get("src", ""))): + try: + height = min(max(int(fields.get("height", "420")), 120), 1600) + except ValueError: + height = 420 + return ( + f'

\n' + ) + url = fields.get("url", "") + if language == "tin-video" and url.startswith(("https://", "http://")): + link = self.safe_url(url) + poster = fields.get("poster", "") + poster_attr = ( + f' data-poster="{self.safe_url(poster)}"' + if poster.startswith(("https://", "http://")) + else "" + ) + return ( + f'
' + f"{title or escape_text(url)}
\n" + ) language_attr = f' data-language="{safe_entity(language)}"' if language else "" code_class = f' class="language-{safe_entity(language)}"' if language else "" return ( @@ -80,6 +137,19 @@ def block_code(self, code: str, info: str | None = None) -> str: f"{escape_text(code)}\n" ) + def block_quote(self, text: str) -> str: + """GitHub's alert syntax (`> [!NOTE]`) as a callout; any other quote as before.""" + match = _CALLOUT.match(text) + if match is None: + return super().block_quote(text) + kind = match[1].lower() + rest = text[match.end() :].lstrip() + rest = rest[len("

") :] if rest.startswith("

") else f"

{rest}" + return ( + f'

\n' + ) + def inline_html(self, html: str) -> str: compact = re.sub(r"\s+", "", html).lower() if compact in _SAFE_INLINE_HTML: @@ -99,8 +169,10 @@ def handle_data(self, data: str) -> None: self.parts.append(data) -def render_markdown(markdown: str) -> RenderedMarkdown: - renderer = _TinHTMLRenderer() +def render_markdown(markdown: str, *, asset_folder: str | None = None) -> RenderedMarkdown: + """Safe HTML for the reader. With an article's `asset_folder`, its figures, embeds and + videos render as placeholders the reader fills in; otherwise as ordinary Markdown.""" + renderer = _TinHTMLRenderer(asset_folder) parser = mistune.create_markdown( renderer=renderer, plugins=["strikethrough", "table", "task_lists", "url"], diff --git a/src/tin_lite/page_assets.py b/src/tin_lite/page_assets.py index 1d400209..a4980230 100644 --- a/src/tin_lite/page_assets.py +++ b/src/tin_lite/page_assets.py @@ -126,3 +126,26 @@ async def verified(storage, *, repo_id: str, revision: str, binding: list[dict]) } ) return assets + + +def _count(number: int, noun: str) -> str: + return f"{number} {noun}" if number == 1 else f"{number} {noun}s" + + +def attachments_line(checkpoint: dict | None, article: str) -> str | None: + """What a reviewer will see besides the words, for the Decisions card, or None.""" + kept = [item["media_type"] for item in (checkpoint or {}).get("assets") or []] + figures = kept.count("image/svg+xml") + len( + re.findall(r"(?m)^(?:`{3,}|~{3,})\s*mermaid\b", article) + ) + embeds = kept.count("text/html") + videos = len(re.findall(r"(?m)^(?:`{3,}|~{3,})\s*tin-video\b", article)) + parts = [ + _count(number, noun) + for number, noun in ((figures, "figure"), (embeds, "interactive piece"), (videos, "video")) + if number + ] + if not parts: + return None + listed = parts[0] if len(parts) == 1 else f"{', '.join(parts[:-1])} and {parts[-1]}" + return f"With {listed}." diff --git a/src/tin_lite/static/app.css b/src/tin_lite/static/app.css index f4e66954..80fb9154 100644 --- a/src/tin_lite/static/app.css +++ b/src/tin_lite/static/app.css @@ -8384,6 +8384,97 @@ html[data-theme="dark"] .integration-badge.is-monochrome::before { border-radius: 4px; } +/* Page bundles: a draft's figures, embeds, videos and callouts. */ +.md-figure, +.md-embed, +.md-video { + margin: 4px 0 34px; +} + +.md-figure img { + display: block; + max-width: 100%; + height: auto; + margin: 0 auto; +} + +.md-figure figcaption, +.md-embed figcaption, +.md-video figcaption { + margin-top: 10px; + color: var(--ink-muted); + font-size: 13px; + line-height: 18px; + text-align: center; +} + +.md-embed-frame { + display: block; + width: 100%; + background: var(--paper-card); + border: 1px solid var(--paper-border); + border-radius: var(--radius-card); +} + +.md-video-frame, +.md-video-file { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + background: #000; + border: 0; + border-radius: var(--radius-card); +} + +.md-embed-missing, +.md-asset-notes { + color: var(--ink-muted); + font-family: var(--mono); + font-size: 11px; + line-height: 16px; +} + +.md-embed-missing { + padding: 24px; + background: var(--paper-deep); + border-radius: 4px; + text-align: center; +} + +.md-asset-notes { + margin: 0 0 24px; +} + +.md-callout { + margin: 0 0 30px; + padding: 16px 20px; + background: var(--paper-well); + border-radius: var(--radius-card); +} + +.md-callout p { + margin: 0 0 8px; +} + +.md-callout p:last-child { + margin-bottom: 0; +} + +.md-callout .md-callout-label { + margin-bottom: 6px; + color: var(--ink-secondary); + font-family: var(--mono); + font-size: 11px; + line-height: 14px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.md-callout[data-kind="warning"] .md-callout-label, +.md-callout[data-kind="caution"] .md-callout-label { + color: var(--danger); +} + .md-image-fallback { display: flex; min-height: 300px; diff --git a/src/tin_lite/static/app.js b/src/tin_lite/static/app.js index e3b4b401..42a1794a 100644 --- a/src/tin_lite/static/app.js +++ b/src/tin_lite/static/app.js @@ -3276,6 +3276,7 @@ function renderDocument() { ? cached.path || run?.artifact_path : null; state.documentCleanup = window.TinMarkdownViewer.mount(main, cached, { mode: "in-app", + loadAsset: route.source !== "retained" ? bundleAssetLoader(cached.revision) : undefined, contextLabel: route.source === "retained" && run?.retained_output?.reason === "execution_interrupted" ? `Partial result · ${cached.filename}` : undefined, pathElement: projectPath ? projectFilePathElement({ path: projectPath }, "markdown-filename") : undefined, @@ -5613,6 +5614,14 @@ function projectFileRawUrl(route, download = false, projectId = state.project.id return `/api/projects/${encodeURIComponent(projectId)}/files/raw?${query.toString()}`; } +// A draft's figures and embeds, read at the document's revision with the member's session. +function bundleAssetLoader(revision, projectId = state.project?.id) { + if (!revision || !projectId) return undefined; + return (path) => authorizedFetch(projectFileRawUrl({ path, revision }, false, projectId), { + headers: { Accept: "*/*" }, + }).then((response) => response.arrayBuffer()); +} + function formatFileSize(bytes) { if (bytes < 1024) return `${bytes} b`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} kb`; @@ -5962,6 +5971,7 @@ function renderFile() { if (cached?.kind === "markdown") { state.documentCleanup = window.TinMarkdownViewer.mount(main, cached.document, { mode: "in-app", + loadAsset: bundleAssetLoader(route.revision), contextLabel: route.path, pathElement: projectFilePathElement(route, "markdown-filename"), factsText: `markdown · ${comparisonFileLabel(route)} · ${shortRevision(route.revision)}`, diff --git a/src/tin_lite/static/markdown-viewer.js b/src/tin_lite/static/markdown-viewer.js index 24127cae..0e1f1b77 100644 --- a/src/tin_lite/static/markdown-viewer.js +++ b/src/tin_lite/static/markdown-viewer.js @@ -2,6 +2,11 @@ (function defineTinMarkdownViewer() { const NUMERIC_CELL = /^[-+]?[$€£]?\d[\d,]*(?:\.\d+)?%?(?:\s+of\s+\d+)?$/i; + // An embed is untrusted: it runs in an opaque-origin frame that can't reach the network. + const EMBED_POLICY = + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " + + "img-src data: blob:; font-src data:; media-src data: blob:"; + const VIDEO_FILE = /\.(?:mp4|webm|m4v|mov)(?:[?#]|$)/i; function makeElement(tagName, className, text) { const element = document.createElement(tagName); @@ -114,6 +119,7 @@ decorateTables(article); decorateImages(article); + decorateBundle(article, documentData, options.loadAsset); decorateDiagrams(article); const cleanupMap = buildSectionMap(article, map, mapInner); return () => cleanupMap(); @@ -133,28 +139,165 @@ }); } + function showImageFallback(image) { + if (!image.isConnected) return; + const frame = makeElement("div", "md-image-fallback"); + frame.setAttribute("role", "img"); + const alt = image.alt.trim(); + const filename = image.dataset.fallbackName || "image"; + frame.setAttribute("aria-label", alt || filename); + frame.innerHTML = + ''; + const label = makeElement( + "span", + "", + [alt, filename].filter((value, index, values) => value && values.indexOf(value) === index).join(" · "), + ); + frame.append(label); + image.replaceWith(frame); + } + function decorateImages(article) { - article.querySelectorAll("img").forEach((image) => { - const showFallback = () => { - if (!image.isConnected) return; - const frame = makeElement("div", "md-image-fallback"); - frame.setAttribute("role", "img"); - const alt = image.alt.trim(); - const filename = image.dataset.fallbackName || "image"; - frame.setAttribute("aria-label", alt || filename); - frame.innerHTML = - ''; - const label = makeElement( - "span", - "", - [alt, filename].filter((value, index, values) => value && values.indexOf(value) === index).join(" · "), - ); - frame.append(label); - image.replaceWith(frame); - }; - image.addEventListener("error", showFallback, { once: true }); - if (image.complete && image.naturalWidth === 0) showFallback(); + // Bundle figures have no src yet; decorateBundle loads them. + article.querySelectorAll("img:not(.md-asset)").forEach((image) => { + image.addEventListener("error", () => showImageFallback(image), { once: true }); + if (image.complete && image.naturalWidth === 0) showImageFallback(image); + }); + } + + // A draft's figures, embeds and videos (page bundles). Files load with the member's session + // through `loadAsset(path)`; nothing in the article is fetched by URL. + function decorateBundle(article, documentData, loadAsset) { + const assets = new Map((documentData.assets || []).map((item) => [item.path, item.media_type])); + if (documentData.asset_notes?.length) { + // Under the title: what the draft referred to that Tin left out, and why. + const notes = makeElement("p", "md-asset-notes", documentData.asset_notes.join(" ")); + const title = article.querySelector("h1"); + if (title) title.after(notes); + else article.prepend(notes); + } + article.querySelectorAll("img.md-asset").forEach((image) => { + wrapFigure(image); + const type = assets.get(image.dataset.asset); + if (!type || !loadAsset) { + showImageFallback(image); + return; + } + loadAsset(image.dataset.asset) + .then((bytes) => imageUrl(new Blob([bytes], { type }))) + .then((url) => { + image.addEventListener("error", () => showImageFallback(image), { once: true }); + image.src = url; + }) + .catch(() => showImageFallback(image)); + }); + article.querySelectorAll("figure.md-embed").forEach((figure) => { + const title = figure.dataset.title || ""; + if (!assets.has(figure.dataset.asset) || !loadAsset) { + figure.append(makeElement("div", "md-embed-missing", "This interactive piece isn't available.")); + return; + } + loadAsset(figure.dataset.asset) + .then((bytes) => { + const frame = document.createElement("iframe"); + frame.className = "md-embed-frame"; + // Scripts run, but in an opaque origin: no Tin cookies, storage, page or network. + frame.setAttribute("sandbox", "allow-scripts"); + frame.setAttribute("referrerpolicy", "no-referrer"); + frame.title = title || "Interactive piece"; + frame.style.height = `${Number(figure.dataset.height) || 420}px`; + frame.srcdoc = embedDocument(new TextDecoder().decode(bytes)); + figure.prepend(frame); + if (title) figure.append(makeElement("figcaption", "", title)); + }) + .catch(() => { + figure.append(makeElement("div", "md-embed-missing", "This interactive piece couldn't load.")); + }); }); + article.querySelectorAll("figure.md-video").forEach((figure) => { + const url = figure.dataset.url || ""; + const title = figure.dataset.title || ""; + const player = videoPlayer(url, figure.dataset.poster, title); + if (!player) return; // The link the server rendered stays. + figure.replaceChildren(player); + if (title) figure.append(makeElement("figcaption", "", title)); + }); + } + + function wrapFigure(image) { + const parent = image.parentElement; + if (parent?.tagName !== "P" || parent.childNodes.length !== 1) return; + const figure = makeElement("figure", "md-figure"); + parent.replaceWith(figure); + figure.append(image); + if (image.title) figure.append(makeElement("figcaption", "", image.title)); + } + + function imageUrl(blob) { + if (blob.type !== "image/svg+xml") return Promise.resolve(URL.createObjectURL(blob)); + // As in Files: an SVG becomes a data URL, inert in and opaque if opened directly. + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(new Error("Tin could not load this figure.")); + reader.readAsDataURL(blob); + }); + } + + function embedDocument(html) { + const policy = ``; + const doctype = html.match(/^\s*]*>/i); + return doctype ? doctype[0] + policy + html.slice(doctype[0].length) : policy + html; + } + + // Players from a short list of hosts; a video file plays in place and loads only on play. + function videoPlayer(url, poster, title) { + let parsed; + try { + parsed = new URL(url); + } catch (_error) { + return null; + } + if (!["https:", "http:"].includes(parsed.protocol)) return null; + if (VIDEO_FILE.test(parsed.pathname)) { + const video = document.createElement("video"); + video.className = "md-video-file"; + video.controls = true; + video.preload = "none"; + video.playsInline = true; + video.src = parsed.href; + if (poster?.startsWith("https://")) video.poster = poster; + return video; + } + const src = providerEmbed(parsed); + if (!src) return null; + const frame = document.createElement("iframe"); + frame.className = "md-video-frame"; + frame.src = src; + frame.title = title || "Video"; + frame.loading = "lazy"; + frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-presentation allow-popups"); + frame.setAttribute("allow", "fullscreen; picture-in-picture; encrypted-media"); + frame.setAttribute("referrerpolicy", "strict-origin-when-cross-origin"); + return frame; + } + + function providerEmbed(url) { + const host = url.hostname.replace(/^www\./, ""); + const segments = url.pathname.split("/").filter(Boolean); + const id = (value) => (/^[A-Za-z0-9_-]{4,64}$/.test(value || "") ? value : null); + if (host === "youtube.com" || host === "m.youtube.com") { + const video = id(url.searchParams.get("v")) || (segments[0] === "shorts" ? id(segments[1]) : null); + return video && `https://www.youtube-nocookie.com/embed/${video}`; + } + if (host === "youtu.be") return id(segments[0]) && `https://www.youtube-nocookie.com/embed/${segments[0]}`; + if (host === "vimeo.com") return /^\d+$/.test(segments[0] || "") && `https://player.vimeo.com/video/${segments[0]}`; + if (host === "loom.com" && segments[0] === "share") return id(segments[1]) && `https://www.loom.com/embed/${segments[1]}`; + if (host === "stream.mux.com" || host === "player.mux.com") { + const playback = id((segments[0] || "").replace(/\.m3u8$/, "")); + return playback && `https://player.mux.com/${playback}`; + } + return null; } function decorateDiagrams(article) { diff --git a/tests/js/procedure-output.cjs b/tests/js/procedure-output.cjs index ce983565..312a1bcd 100644 --- a/tests/js/procedure-output.cjs +++ b/tests/js/procedure-output.cjs @@ -35,7 +35,7 @@ const context = vm.createContext({ }); vm.runInContext([ "workflowForRun", "availableRunOutput", "outputReadUrl", "retainedOutputMessage", "isMarkdownPath", - "openRunOutputFile", "openRunArtifact", "hasOutputConflict", "runFingerprint", "renderDocument", + "openRunOutputFile", "openRunArtifact", "hasOutputConflict", "runFingerprint", "renderDocument", "bundleAssetLoader", ].map(extract).join("\n"), context); assert.equal(context.availableRunOutput(run).source, "retained"); context.openRunArtifact("run"); diff --git a/tests/test_article_search_structure.py b/tests/test_article_search_structure.py index 59a7bf64..c2f282a0 100644 --- a/tests/test_article_search_structure.py +++ b/tests/test_article_search_structure.py @@ -52,8 +52,8 @@ def test_listing_does_not_excuse_a_missing_title(): @pytest.mark.parametrize( ("key", "version", "skill"), [ - ("content.public_article", "1.8.0", "search-and-answer-engines"), - ("content.generate", "1.9.0", "search-and-answer-engines"), + ("content.public_article", "1.9.0", "search-and-answer-engines"), + ("content.generate", "1.10.0", "search-and-answer-engines"), ], ) def test_blog_workflows_pin_the_search_structure_guidance(key, version, skill): diff --git a/tests/test_content_draft.py b/tests/test_content_draft.py index 5781f01d..b613c113 100644 --- a/tests/test_content_draft.py +++ b/tests/test_content_draft.py @@ -45,6 +45,8 @@ async def fixture(db, monkeypatch, *, editorial=False, clean=False, judgment=Fal output_validator=( content_draft.CLEAN_VALIDATOR if clean else content_draft.VALIDATOR ), + # Historical contracts had no assets folder. + output_assets=None, ), ) if assets: diff --git a/tests/test_one_content_generate.py b/tests/test_one_content_generate.py index 2b60963d..a5fa672d 100644 --- a/tests/test_one_content_generate.py +++ b/tests/test_one_content_generate.py @@ -268,7 +268,7 @@ def connected(f, monkeypatch): def test_the_definition_drafts_three_kinds_with_a_review_policy_each(): definition = spec(content_draft.KEY).definition - assert definition["version"] == "1.9.0" + assert definition["version"] == "1.10.0" assert content_draft.supported_kinds(definition) == ("article", "answer", "refresh") assert definition["human_review"]["review_label"] == "Review article" kinds = definition["human_review_kinds"] @@ -652,7 +652,8 @@ def test_versions_on_main_are_unchanged(): # The versions #267 introduced, none of them on main yet. # content.plan 0.9.0 (content-editorial-v8) raised 0.8.0's output cap and changed nothing else. -RELEASED = {"content.generate": "1.9.0", "content.plan": "0.9.0", "content.refresh": "1.1.0"} +# content.generate 1.10.0 lets an article carry figures and embeds (page bundles). +RELEASED = {"content.generate": "1.10.0", "content.plan": "0.9.0", "content.refresh": "1.1.0"} async def test_retired_workflows_are_hidden_but_saved_configurations_still_run( diff --git a/tests/test_page_assets.py b/tests/test_page_assets.py index 94ec2e4c..fbf2614c 100644 --- a/tests/test_page_assets.py +++ b/tests/test_page_assets.py @@ -80,7 +80,9 @@ def test_only_article_drafts_declare_an_assets_folder(): with pytest.raises(ValueError, match="Only article drafts"): validate_codex_procedure_definition(diagram) # Definitions without assets are unchanged, so pinned runs keep their exact contract. - plain, _ = spec.definition_and_resource_files() + plain, _ = replace( + spec, procedure=replace(spec.procedure, output_assets=None) + ).definition_and_resource_files() assert "assets" not in plain["procedure"]["output"] # The sandbox learns where the article's files go, and the runner's limits. pinned = PinnedCodexProcedure( @@ -361,3 +363,67 @@ def test_the_wording_check_skips_embed_video_and_diagram_blocks(): kept = FIGURE_FENCE.sub("\n\n", text) assert "height" not in kept and "graph LR" not in kept assert "Intro words here." in kept and "print(1)" in kept + + +def test_the_reader_renders_figures_embeds_videos_and_callouts(): + from tin_lite.documents import render_markdown + + markdown = ( + '# T\n\n![Flow](./a.assets/flow.svg "What to notice")\n\n' + "```tin-embed\nsrc: ./a.assets/field.html\nheight: 9999\ntitle: Try it\n```\n\n" + "```tin-embed\nsrc: ../../secrets.html\n```\n\n" + "```tin-video\nurl: https://www.youtube.com/watch?v=dQw4w9WgXcQ\ntitle: Trailer\n```\n\n" + "```tin-video\nurl: javascript:alert(1)\n```\n\n" + "> [!WARNING]\n> Mind the gate.\n\n> A plain quote.\n" + ) + html = render_markdown(markdown, asset_folder="content/articles/a.assets").html + # A figure has no URL; the reader loads it with the member's session. + assert 'class="md-asset" data-asset="content/articles/a.assets/flow.svg"' in html + assert 'src="./a.assets' not in html + assert 'data-height="1600" data-title="Try <b>it</b>"' in html + # A path outside the folder and a script URL stay plain code. + assert "src: ../../secrets.html" in html and "javascript:alert" in html + assert html.count('class="md-embed"') == 1 and html.count('class="md-video"') == 1 + assert '