From f048b53107a5308131d5feb64ed4aabb98255f73 Mon Sep 17 00:00:00 2001 From: Juan Nicolas Herrera <54152074+juanNH@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:41:38 -0300 Subject: [PATCH 1/3] feat: polish profile editor and card exports --- AGENTS.md | 3 + docs/README.md | 2 + docs/architecture.md | 6 + docs/decisions/0035-compact-technologies.md | 4 +- .../0042-custom-section-title-visibility.md | 25 ++ .../0043-public-card-export-check.md | 31 ++ docs/editor.md | 12 +- docs/profile-model.md | 4 +- docs/vercel-deployment.md | 2 + scripts/check-public-cards.mjs | 4 + scripts/check-public-cards.test.mjs | 16 + src/app/api/cards/export-check/route.test.ts | 29 ++ src/app/api/cards/export-check/route.ts | 20 + src/components/ui/code-editor-surface.tsx | 127 +++--- src/core/AGENTS.md | 2 + src/core/activity/export-check.ts | 28 ++ src/core/profile/custom-content.ts | 1 + src/core/readme/activity-readme.test.ts | 30 ++ src/core/readme/compact-technologies.test.ts | 5 +- .../readme/custom-title-visibility.test.ts | 139 +++++++ src/core/readme/render-custom.ts | 2 +- src/core/readme/render-technologies.ts | 27 +- .../editor/activity-export-readiness.tsx | 89 +++++ src/features/editor/editor-workspace.tsx | 50 ++- .../editor/profile-html-preview.test.tsx | 17 + src/features/editor/profile-html-preview.tsx | 13 +- .../editor/use-activity-export-check.ts | 81 ++++ src/messages/en/editor.json | 24 ++ src/messages/en/modules/custom.json | 16 +- src/messages/es/editor.json | 24 ++ src/messages/es/modules/custom.json | 16 +- src/modules/AGENTS.md | 2 + src/modules/custom/code-block-fields.tsx | 88 +++-- src/modules/custom/custom-section-form.tsx | 50 ++- .../custom/custom-text-formatting.test.ts | 11 + src/modules/custom/custom-text-formatting.ts | 12 +- src/server/AGENTS.md | 2 + src/server/activity-export-check.test.ts | 363 ++++++++++++++++++ src/server/activity-export-check.ts | 208 ++++++++++ tests/e2e/activity-export-check.spec.ts | 116 ++++++ tests/e2e/custom-code-format.spec.ts | 29 +- tests/e2e/custom-html-form.spec.ts | 4 +- tests/e2e/custom-keyboard.spec.ts | 6 +- tests/e2e/custom-sections.spec.ts | 87 ++++- tests/e2e/custom-visual-blocks.spec.ts | 6 +- tests/e2e/custom-visual-keyboard.spec.ts | 2 +- tests/e2e/editor-responsive.spec.ts | 4 +- tests/e2e/editor-scroll.spec.ts | 4 +- tests/e2e/editor-top-navigation.spec.ts | 4 +- tests/e2e/exported-activity-cards.spec.ts | 21 +- tests/e2e/exported-cards.config.ts | 2 +- .../exported-cards-provider-fixture.mjs | 9 + tests/e2e/project-files.spec.ts | 31 ++ tests/e2e/technology-layout.spec.ts | 98 ++++- 54 files changed, 1832 insertions(+), 176 deletions(-) create mode 100644 docs/decisions/0042-custom-section-title-visibility.md create mode 100644 docs/decisions/0043-public-card-export-check.md create mode 100644 src/app/api/cards/export-check/route.test.ts create mode 100644 src/app/api/cards/export-check/route.ts create mode 100644 src/core/activity/export-check.ts create mode 100644 src/core/readme/custom-title-visibility.test.ts create mode 100644 src/features/editor/activity-export-readiness.tsx create mode 100644 src/features/editor/use-activity-export-check.ts create mode 100644 src/server/activity-export-check.test.ts create mode 100644 src/server/activity-export-check.ts create mode 100644 tests/e2e/activity-export-check.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 4da1f6c..6886822 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,9 @@ The current release includes the Next.js foundation, a bilingual visual kit, `Pr ## Architecture and code +- Custom section `title` is the editor name. V2 `showTitle?: boolean` controls only its exported heading; absence/true preserves earlier output and false retains the name and formatting while omitting title validation and HTML budget. Adapt implicit subheadings to the visible title, preserve explicit levels/HTML, and retain the flag through duplication and JSON. Do not add defaults on recovery. See [ADR 0042](docs/decisions/0042-custom-section-title-visibility.md). +- A working browser card preview does not prove public image access. With configured cards, export requires an explicit anonymous check of the exact canonical URLs, valid for five minutes and invalidated when URLs change. Never query during mounting/recovery/edits, follow redirects, forward cookies/tokens, or accept arbitrary hosts. Keep server concurrency/body/time/rate limits and temporary editor state outside the profile and data cache. See [ADR 0043](docs/decisions/0043-public-card-export-check.md). + - Use the Next.js App Router and strict TypeScript. Keep routes and layouts small; put behavior in the scope that owns it. - Keep English as the default language and Spanish as a fully supported option. Use `next-intl`, routes under `src/app/[locale]`, and message namespaces; avoid hard-coded interface text in components. Changing languages preserves the equivalent route, uses language names without flags, and keeps the correct `lang` attribute. - Prefer Server Components. Use `"use client"` at the smallest boundary that needs state, events, or browser APIs. Do not convert an entire layout into a client component for convenience. diff --git a/docs/README.md b/docs/README.md index 808981e..bbee18b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,6 +58,8 @@ The GitHub Profile Generator documentation describes the product, its available | [Detailed contributions](decisions/0039-contribution-details-and-order.md) | Multiple links, public images, legacy repository preview compatibility, and contribution order. | | [Activity card columns](decisions/0040-activity-card-columns.md) | Optional one- or two-column card rows and legacy output behavior. | | [Activity and contribution order](decisions/0041-activity-contribution-order-and-readable-cards.md) | Choose content order and keep two-column card text readable. | +| [Internal section names](decisions/0042-custom-section-title-visibility.md) | Keep section names in the editor while optionally hiding README headings. | +| [Public card export checks](decisions/0043-public-card-export-check.md) | Explicit anonymous image checks, deployment failures, and export readiness. | | [Third-party notices](../THIRD_PARTY_NOTICES.md) | Provenance and licenses for icons, flags, the code editor, and Extended cards. | | [Contributing](../CONTRIBUTING.md) | Workflow, expectations for changes, and pull requests. | diff --git a/docs/architecture.md b/docs/architecture.md index ad55689..efd9a0f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,6 +128,12 @@ The contact renderer adds a decorative 20 × 20 icon inside a recognized link an Icons identify tools and services without inferring skills or integrating providers. Some entries share a family brand, such as JetBrains AI or ChatGPT/OpenAI; these mappings are documented in attribution. See [ADR 0009](decisions/0009-profile-icons.md). +## Internal Section Names and Exported Titles + +Custom sections keep an authored editor name in `title`. The optional v2 `showTitle` flag defaults to visible only by interpretation, without adding a property during parsing. `false` omits the generated title and its HTML budget/errors while retaining the internal name and formatting. Implicit subheadings use the untitled hierarchy; explicit HTML is unchanged. Navigation and the configuration heading use the internal name regardless of export visibility. See [ADR 0042](decisions/0042-custom-section-title-visibility.md). + +Public card availability remains outside the pure generator. A small explicit-check endpoint validates the generated URLs against the configured origin and checks their anonymous HEAD responses. Its bounded network service is in `server/activity-export-check`; temporary readiness and cancellation live in the editor. No token, provider data or new profile field is part of this check. See [ADR 0043](decisions/0043-public-card-export-check.md). + ## Custom images in custom sections The document retains `image` as `{ id, type: "image", url, alt }`. `normalizePublicImageUrl` is a pure function that accepts syntactically public-domain HTTPS, without an IP address, local host or known private suffix, credentials, fragments, or non-standard port. It does not require a file extension and allows a query string. It does not query DNS, network, or availability; an accepted URL does not prove that the response is an image or will remain published. The application does not upload files or turn local, relative, `blob:`, or `data:` paths into public resources. diff --git a/docs/decisions/0035-compact-technologies.md b/docs/decisions/0035-compact-technologies.md index 4115033..4fce44d 100644 --- a/docs/decisions/0035-compact-technologies.md +++ b/docs/decisions/0035-compact-technologies.md @@ -13,11 +13,11 @@ Add `data.technologies.layout?: "compact" | "grouped"` exclusively to v2. Its ab The “README layout” selector offers compact tags and category headings. Changing it updates the same Markdown used by preview, copy, and download. It does not regroup, rename, or remove categories, items, identities, or notes. All form edits and organization operations preserve the selected option. -Compact mode displays the category name in bold on the same row as its tools, wrapping to fit the width. Each name is wrapped in `kbd` and, when available, includes the catalog's pinned 20 × 20 decorative icon. Custom names remain text without an inferred brand. Notes keep their order and paragraphs; they are not hidden to reduce height. Groups with no useful content are omitted. +Compact mode displays the category name in bold on the same row as its tools, wrapping to fit the width. Each catalog tool's pinned 20 × 20 decorative icon and visible name share one `kbd`, so the badge background covers both. Custom names remain text without an inferred brand. Notes keep their order and paragraphs; they are not hidden to reduce height. Groups with no useful content are omitted. HTML is built from HAST text nodes, filtered with `renderProfileHtml`, and shares its limits and budget with the rest of the document. Exact UTF-16 ranges and allowed trees are provided through `customHtml`, without expanding `customImages`, enabling global HTML, or persisting metadata. `invalid_html` errors identify the affected group; the UI offers to fix it or return to category headings. Hidden technologies do not use the budget. -The preview recognizes only exact catalog URLs, an empty alternative text value, and 20 × 20 dimensions in order to use local SVGs. Compact tags use inline-flex alignment and bounded wrapping in the local preview so the icon and label occupy the available chip area without clipping. The rest of the image and HTML policy remains in place. No third-party badges are requested, no endpoint is created, and `APP_URL` is not required. Colors come from existing icons. GitHub controls the published `kbd` styling, so matching its appearance pixel-for-pixel is not promised. +The preview recognizes only exact catalog URLs, an empty alternative text value, and 20 × 20 dimensions in order to use local SVGs. It places the icon and label in the same badge, matching the exported HTML rather than relying on preview-only layout. The reader controls badge colors and wrapping; at extreme widths, rendering can differ between Markdown readers. The rest of the image and HTML policy remains in place. No third-party badges are requested, no endpoint is created, and `APP_URL` is not required. ## Alternatives Considered diff --git a/docs/decisions/0042-custom-section-title-visibility.md b/docs/decisions/0042-custom-section-title-visibility.md new file mode 100644 index 0000000..7327590 --- /dev/null +++ b/docs/decisions/0042-custom-section-title-visibility.md @@ -0,0 +1,25 @@ +# ADR 0042: Internal Section Names and Optional README Titles + +- Status: accepted. +- Date: 2026-10-23. +- Extends [ADR 0013](0013-custom-sections.md) and [ADR 0019](0019-code-and-formatting.md). + +## Context + +A person may want a recognizable name in the editor without publishing a heading above the section's content. Using an empty title for both purposes makes several sections difficult to distinguish. + +## Decision + +Keep `CustomSection.title` as the authored name used in editor navigation and the configuration heading. Add `showTitle?: boolean` to the strict v2 schema. Absence and `true` preserve previous README output; `false` omits the generated section heading. Parsing and recovery do not add defaults, change storage keys, or modify v1. + +The **Section name** field remains editable. **Show title in README** controls only the generated heading, independently from including the whole section. Unchecking retains the name, title level, alignment, blocks, identity and position. Checking again restores the saved formatting and removes the optional visibility override. Duplicating and JSON project files preserve the choice. + +A hidden title does not consume the HTML budget or produce title-format export errors. Implicit subheadings use the same hierarchy as a section with no title. Explicit heading levels and authored HTML remain unchanged. Preview, copy and download continue to use the same generated Markdown and verified `customHtml` ranges. + +## Alternatives and Consequences + +- A separate name field would require two authored strings, recovery rules and another source of labels. Reusing the existing title keeps this bounded. +- Clearing the title loses the editor name. Hiding it with CSS would still publish it and does not work across Markdown readers. +- An older strict v2 reader can reject `showTitle`; it must preserve that incompatible draft. Existing documents without the property keep their shape and output. + +Review this decision if internal names and exported titles must become independently authored text. diff --git a/docs/decisions/0043-public-card-export-check.md b/docs/decisions/0043-public-card-export-check.md new file mode 100644 index 0000000..8d64581 --- /dev/null +++ b/docs/decisions/0043-public-card-export-check.md @@ -0,0 +1,31 @@ +# ADR 0043: Explicit Public Card Checks Before Export + +- Status: accepted. +- Date: 2026-10-23. +- Complements [ADR 0036](0036-production-card-origin.md) and [ADR 0041](0041-activity-contribution-order-and-readable-cards.md). + +## Context + +The editor renders cards from browser snapshots into temporary Blob images. An HTTPS origin that passes syntax validation may still require a hosting session, return HTML, lack newly generated options, or have no available provider data. A protected Vercel deployment redirects anonymous images to SSO, and a deployment older than the two-column contract rejects `columns=2`. A working local preview proves neither public availability nor deployment compatibility. + +## Decision + +Keep the pure generator and its configured-origin requirement. Before copying or downloading a README with cards, offer **Check public cards** as an explicit action. Do not query while mounting, restoring a draft, changing options or displaying the preview. Without cards, export remains immediate. A successful result unlocks export for five minutes for the exact ordered image URLs. Changing those URLs, removing cards, reloading, or expiration invalidates the result without a new request. Ordinary text edits do not change card availability. Verification is temporary UI state, outside the profile, Markdown, backups and data caches. + +Use `POST /api/cards/export-check` with `{ sources: string[] }`. The server parses canonical card URLs only against its configured public origin. It never accepts another host, a redirect destination, a non-card path or arbitrary fetch options. The result is a small validated union in `core/activity/export-check`, with no response bodies, credentials or infrastructure headers. + +The server sends anonymous `HEAD` requests with `credentials: omit`, `redirect: manual` and `cache: no-store`. It forwards no incoming cookies, authorization or headers. HTTP 200, an SVG MIME type and `X-Activity-State: ready | stale` are required. Redirects or 401/403 indicate protected access; 400/404 indicate incompatible routes/options; an unavailable image, invalid response, network failure, timeout or busy process has a separate result. Stale images are reported as cached data. + +Bounds per process are 32 input URLs, 12,000 characters per URL, 512 KiB of JSON, five seconds to read the body, four simultaneous outbound requests, one concurrent check, 12 checks and 60 outbound requests per minute. Requests have 15-second timeouts and a 30-second overall check budget. Duplicate sources are checked once. Failure or client cancellation stops pending work; no automatic retries or unbounded queues are added. These are process limits, not distributed quotas. + +The editor explains failure, offers a manual retry, opens an exported image in another tab, and lets the person exclude cards while keeping settings. It does not download a knowingly failed set. The separate verification action also keeps clipboard writes inside a later user click, avoiding lost clipboard activation after a network wait. + +## Alternatives and Consequences + +- Checking only origin syntax misses protected and incompatible deployments. Silently dropping exported options would change the selected output. +- Following redirects could accept a login page or contact another host. Loading the image with visitor cookies would test their session instead of anonymous access. +- Automatic checks on each edit add traffic and contradict the explicit-query flow. Hosting static snapshots would require a new upload and publication flow. +- Checks can time out on a cold or slow service; the UI reports an incomplete check and allows manual retry. Existing server/CDN caches still apply. HEAD headers do not certify future availability, the SVG body, GitHub Camo or rendering in every reader. +- Operators must publish a compatible version at a stable public origin. This check changes neither deployment protection nor hosting configuration. Previously downloaded READMEs retain their old URLs and need regenerating after configuration is corrected. + +The command-line card checker also accepts the bounded `columns=1|2` option emitted by the current generator. diff --git a/docs/editor.md b/docs/editor.md index ad1de7c..4b89fb8 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -78,11 +78,13 @@ An empty visible section does not add a heading without content. A technology gr ### Custom sections -You can add multiple custom sections and order them among the fixed sections. Each accepts an optional title and ten content types: text, bulleted list, numbered list (1., 2., 3.), web link, subheading, divider, quote, image/banner, HTML and code. The selector shows small decorative local previews of each layout, with accessible names and descriptions in both languages. Blocks are added without example text or images. List bullets and numbers are added automatically: each field contains only the text of one item. Repeated titles are valid; navigation identifies each instance without changing the text shown in the README. Without a title, only the body is exported; a title with no useful content does not create an empty heading. +You can add multiple custom sections and order them among the fixed sections. **Section name** identifies the section in the editor navigation and configuration heading. **Show title in README** independently controls whether that name is exported as a heading; uncheck it to publish only the content while keeping an internal name. Title size and alignment remain saved when hidden. Existing drafts keep their previous visible titles. Repeated names are valid, and a blank name uses the default editor label. A heading is exported only when the section has useful content. See [ADR 0042](decisions/0042-custom-section-title-visibility.md). + +Each section accepts ten content types: text, bulleted list, numbered list (1., 2., 3.), web link, subheading, divider, quote, image/banner, HTML and code. The selector shows small decorative local previews of each layout, with accessible names and descriptions in both languages. Blocks are added without example text or images. List bullets and numbers are added automatically: each field contains only the text of one item. Content buttons work with the keyboard and focus the new entry; the divider has no fields, so its heading receives focus. Arrow keys reorder blocks, and their remove actions delete only that content while preserving a focus target for the next action. Add, remove and move actions are announced in English and Spanish. Links accept `http://` and `https://` with an optional label; `mailto:` remains available only in Contact. -A subheading creates a lower-level heading within a titled section (`###`); if the section has no title, it uses `##` to avoid skipping that hierarchy. A divider generates a horizontal rule (`---`). A quote uses a Markdown block quote (`>`) and preserves the lines and paragraphs written inside it. HTML or Markdown typed in these fields is still escaped as text. +A subheading creates a lower-level heading within a section with a visible title (`###`); if the title is blank or hidden, it uses `##` to avoid skipping that hierarchy. Explicitly chosen heading levels remain unchanged. A divider generates a horizontal rule (`---`). A quote uses a Markdown block quote (`>`) and preserves the lines and paragraphs written inside it. HTML or Markdown typed in these fields is still escaped as text. In a custom text block, starting the first line with `- ` turns it into a bulleted list. Enter adds an item; at the last empty item it ends the list and lets you continue with text. Pasting multiple lines creates multiple items. **Convert to text** preserves their content and markers. This assistance applies only to custom sections: the introduction, notes and fixed fields do not interpret lists or free-form Markdown. @@ -187,13 +189,15 @@ A card rendered in the editor does not prove that GitHub can load it: the previe README images are updated according to the service cache; they are not snapshots stored in the document. The update date and period belong to the queried data; a failure does not turn an older graph into new activity. Public queries must be enabled by the deployment operator, as described in [ADR 0010](decisions/0010-github-activity.md). +When cards have a configured origin, choose **Check public cards** before copying or downloading. This checks their exact addresses without signing in. A successful check enables export for five minutes; changing card URLs requires another explicit check. Text edits do not request new checks. Redirects to hosting login, an older card service that rejects options, missing data and temporary network failures have separate messages. You can retry, open a published image, or exclude cards while preserving their settings. Excluding them leaves text and manual contributions exportable. No check runs on recovery or while editing. See [ADR 0043](decisions/0043-public-card-export-check.md). + The button limits queries while editing. Published cards keep dynamic public URLs and may use quota when refreshed. The [API, credentials and hosting guide](github-api-and-hosting.md) explains the shared PAT, caches and Vercel Hobby limits for server operators. ## Supported text and links Ordinary fields are treated as **plain text**: typing Markdown or HTML does not enable free-form formatting. The generator escapes those characters. The exception is the explicit HTML block, which analyzes and filters its source before generating output. The preview renders the same Markdown with ReactMarkdown 10.1 and `remark-gfm` for tables, without global HTML parsing and with an explicit allowlist of elements. The editor's fonts, colors and layout are not embedded in the README; its final appearance depends on GitHub. -Recognized technologies are exported with a decorative 20 × 20 icon and escaped display name. The generator builds `img` tags exclusively from the curated catalog, using URLs pinned for Devicon, Simple Icons and Lobe Icons. Custom names remain text; notes are kept as escaped text. Category titles are exported when they contain entries. The preview respects order and permits line breaks; it continues to accept the earlier 32 × 32 tokens. No CSS is added to the README. +Recognized technologies are exported with a decorative 20 × 20 icon and escaped display name. In compact mode each icon and name share one `kbd` badge, so its background covers both. The generator builds `img` tags exclusively from the curated catalog, using URLs pinned for Devicon, Simple Icons and Lobe Icons. Custom names remain text; notes are kept as escaped text. Category titles are exported when they contain entries. The preview reflects the exported composition and continues to accept earlier 32 × 32 tokens. Each reader controls colors and wrapping; no CSS is added to the README. Recognized contacts include a 20 × 20 icon inside the link, built from the Simple Icons catalog. The icon has `alt=""` because the visible text already names the link; it does not duplicate its accessible name. The validated URL determines the service by explicit domains: a “LinkedIn” label on a custom website does not assign its brand. Custom icon URLs are not accepted. @@ -235,7 +239,7 @@ The contract and API are described in the [profile model](profile-model.md). Cus ## Code and formatting in custom sections -**Add code** creates an empty block. Paste your source and choose a suggested language (such as TOML, JSON, JavaScript or Python), enter a custom identifier or leave it empty for plain text. The JSON sample is inserted only when selected; replacing existing code requires confirmation. Source is literal: typing tags, scripts, images or Markdown does not execute them or load their resources. +**Add code** creates an empty block. Paste your source and choose a language by its readable name (such as C#, TOML, JSON, JavaScript or Python), select **Custom** to enter another identifier, or choose **Plain text**. Unknown identifiers remain in the README without local highlighting. The JSON sample is inserted only when selected; replacing existing code requires confirmation. Source is literal: typing tags, scripts, images or Markdown does not execute them or load their resources. HTML and code share an editor with colors, line numbers and line wrapping. It loads on demand and offers **Plain text** as an alternative. Tab and Shift+Tab enter and leave the field; indent buttons and Ctrl+] / Ctrl+[ (Cmd on macOS) modify the selection. Examples and mode changes do not automatically format the source. All suggested languages have local highlighting, including C#, Java, PHP and Markdown. An unknown custom identifier remains editable as text and shows specific guidance. GraphQL has lexical highlighting. The editor does not validate program correctness or execute code. diff --git a/docs/profile-model.md b/docs/profile-model.md index 77dc327..f649f7f 100644 --- a/docs/profile-model.md +++ b/docs/profile-model.md @@ -122,7 +122,7 @@ Internal validation preserves the nullable v1 choices: `education.kind` = `degre ## Custom sections -Each instance contains `id`, `title`, `visible`, and `blocks`. The title is optional text and does not determine the instance's identity; duplicate titles are valid. It also supports `titleLevel?: 1 | 2 | 3` and `titleAlignment?: "left" | "center" | "right"`. When these properties are absent, the previous formatting is preserved and they are not added during recovery. Visibility belongs to the instance, outside the fixed-module flags object. +Each instance contains `id`, `title`, `visible`, and `blocks`. The title is optional text and does not determine the instance's identity; duplicate titles are valid. It also supports `showTitle?: boolean`, `titleLevel?: 1 | 2 | 3` and `titleAlignment?: "left" | "center" | "right"`. When these properties are absent, the previous output is preserved and they are not added during recovery. `showTitle: false` keeps `title` as the editor name while omitting the README heading, its validation and HTML budget; implicit subheadings behave as if no title were present. Name, formatting and visibility choice survive inclusion changes, duplication and JSON project files. Whole-section inclusion still uses `visible`, outside the fixed-module flags object. See [ADR 0042](decisions/0042-custom-section-title-visibility.md). | Block | Shape | | --------- | ------------------------------------------------------------------------------- | @@ -144,7 +144,7 @@ Validation is strict and requires non-empty, unique IDs in each collection: sect `code.source` also preserves exactly what was written, but it is never interpreted as HTML or Markdown. `language: ""` represents plain text; the local catalog normalizes aliases and accepts an unknown safe token up to 64 UTF-16 code units. An invalid language or an oversized source blocks export with `invalid_code` at `language` or `source`. Each block allows 30,000 units, and visible code allows 150,000 units in total; hidden blocks do not consume the budget. An exactly empty source is omitted, but intentional whitespace is not. The exported delimiter uses more backticks than any run in the source, which is preserved between delimiter line breaks. -Without explicit formatting, the title uses `##`, and a subtitle uses `###` when the section has a title or `##` when it does not. A chosen level produces `#`, `##`, or `###`. Left alignment preserves escaped Markdown. Center and right alignment build HTML with escaped literal text, validate it under the current policy, and produce `customHtml` metadata with exact ranges, without storing tags in text fields. Derived formatting shares the HTML budgets and reports `invalid_html` at `title` or `text`. Code and lists keep their own layouts. See [ADR 0019](decisions/0019-code-and-formatting.md). +Without explicit formatting, a visible title uses `##`, and a subtitle uses `###` when the section has a visible title or `##` when it does not. A chosen level produces `#`, `##`, or `###`. Left alignment preserves escaped Markdown. Center and right alignment build HTML with escaped literal text, validate it under the current policy, and produce `customHtml` metadata with exact ranges, without storing tags in text fields. Derived formatting shares the HTML budgets and reports `invalid_html` at `title` or `text`. Code and lists keep their own layouts. See [ADR 0019](decisions/0019-code-and-formatting.md). Adding inserts an empty, visible instance after an explicitly specified reference or at the end. Duplicating copies the entire tree, preserves text, order, and visibility, generates new IDs for the section, blocks, and items, and places the reference next to the original. No nested objects are shared with the copy. Hiding changes only `visible`; removing deletes that instance and its reference while preserving the relative order of the others. diff --git a/docs/vercel-deployment.md b/docs/vercel-deployment.md index fc25bae..c27e7cd 100644 --- a/docs/vercel-deployment.md +++ b/docs/vercel-deployment.md @@ -72,6 +72,8 @@ On `dev`, you can use the branch alias assigned by Vercel as the test origin if The effective origin, whether explicit or resolved from Production, and `/cards/...` must respond without a session, cookies, a password, or authentication redirects. Review **Deployment Protection**. You can protect Previews if you do not need to check their images from GitHub; a protected URL cannot be used for that test. Validating the origin syntax does not verify availability or anonymous access. +The editor's **Check public cards** action verifies anonymous HEAD responses at the exact generated URLs before enabling card export. It reports redirects, incompatible routes/options, unavailable data and timeouts. The origin must serve the same card contract as the editor: for example, a deployment older than two-column support rejects `columns=2` with HTTP 400. Publish the matching version at the stable Production origin and configure `APP_URL` for the local editor accordingly; a fixed, protected deployment URL is unsuitable. This does not require disabling Preview protection. Restart the local server after changing its environment, or redeploy after changing Vercel variables. Regenerate old READMEs because downloaded URLs do not update themselves. See [ADR 0043](decisions/0043-public-card-export-check.md). + On October 22, 2026, the `dev` Preview for commit `7b44bdc` was marked **Ready** in Vercel and `/en` opened in a Vercel-authenticated browser session. Anonymous `HEAD` requests to `/en` and `/en/social-preview` returned `302` redirects to Vercel SSO and `X-Robots-Tag: noindex`. This records authenticated access and the observed anonymous redirects only. It does not establish anonymous behavior for `/cards/...`, Production availability, or rendering by GitHub or Camo. Keep SSO enabled and treat the Preview as protected; no card route or remote README image was tested. Cards are outside `/en` and `/es`; the language goes in `?locale=en` or `?locale=es`. Do not add rewrites to the editor. Responses include `image/svg+xml`, a passive CSP, `nosniff`, public caching, and `X-Activity-State`. diff --git a/scripts/check-public-cards.mjs b/scripts/check-public-cards.mjs index b8fe335..61382be 100644 --- a/scripts/check-public-cards.mjs +++ b/scripts/check-public-cards.mjs @@ -204,6 +204,7 @@ function validateCardUrl(url) { invalid("El README contiene una ruta de tarjeta propia no admitida."); const allowed = new Set([ "locale", + "columns", ...appearanceKeys, ...(resource ? ["target", "options"] : ["stats", "languages"]), ]); @@ -217,6 +218,9 @@ function validateCardUrl(url) { const locale = url.searchParams.get("locale"); if (locale !== null && locale !== "en" && locale !== "es") invalid("Una tarjeta contiene un idioma no admitido."); + const columns = url.searchParams.get("columns"); + if (columns !== null && columns !== "1" && columns !== "2") + invalid("Una tarjeta contiene una cantidad de columnas no admitida."); if (resource) { const target = url.searchParams.get("target") ?? ""; if ( diff --git a/scripts/check-public-cards.test.mjs b/scripts/check-public-cards.test.mjs index c859183..a2e7f07 100644 --- a/scripts/check-public-cards.test.mjs +++ b/scripts/check-public-cards.test.mjs @@ -134,6 +134,22 @@ test("README: incluye Gist y WakaTime sin usuario GitHub", () => { assert.equal(urls.length, 2); }); +test("README: admite una o dos columnas de perfil y recursos, sin valores libres", () => { + for (const columns of ["1", "2"]) { + const profile = `${url}&columns=${columns}`; + const resource = `${origin}/cards/extended/repository.svg?locale=en&target=alex%2Frepo&columns=${columns}`; + assert.deepEqual( + collectCardUrls({ origin, source: `![Perfil](${profile})\n![Repo](${resource})` }), + [profile, resource], + ); + } + for (const columns of ["0", "3", "02", "", "two", "2&columns=1"]) { + assert.throws(() => + collectCardUrls({ origin, source: `![Perfil](${url}&columns=${columns})` }), + ); + } +}); + test("README: ignora hosts ajenos, prefijos engañosos, enlaces y código literal", () => { const source = `![Real](${url}) ![Externo](https://outside.example.com/cards/github/alex/summary.svg?locale=es) diff --git a/src/app/api/cards/export-check/route.test.ts b/src/app/api/cards/export-check/route.test.ts new file mode 100644 index 0000000..9df6789 --- /dev/null +++ b/src/app/api/cards/export-check/route.test.ts @@ -0,0 +1,29 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ActivityExportCheckResult } from "@/core/activity/export-check"; + +const { check } = vi.hoisted(() => ({ check: vi.fn() })); +vi.mock("@/server/activity-export-check", () => ({ checkActivityExportRequest: check })); + +import { POST } from "./route"; + +beforeEach(() => check.mockReset()); + +describe("export card check route", () => { + it.each<[ActivityExportCheckResult, number]>([ + [{ status: "ready", stale: false }, 200], + [{ status: "failed", reason: "protected" }, 200], + [{ status: "failed", reason: "invalid_request" }, 400], + [{ status: "failed", reason: "busy" }, 503], + ])("returns bounded serializable status without caching: %j", async (result, status) => { + check.mockResolvedValue(result); + const request = new Request("https://editor.example.com/api/cards/export-check", { + method: "POST", + }); + const response = await POST(request); + expect(check).toHaveBeenCalledWith(request); + expect(response.status).toBe(status); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(await response.json()).toEqual(result); + }); +}); diff --git a/src/app/api/cards/export-check/route.ts b/src/app/api/cards/export-check/route.ts new file mode 100644 index 0000000..a2ca8c1 --- /dev/null +++ b/src/app/api/cards/export-check/route.ts @@ -0,0 +1,20 @@ +import { checkActivityExportRequest } from "@/server/activity-export-check"; + +export const runtime = "nodejs"; +export const maxDuration = 45; + +export async function POST(request: Request) { + const result = await checkActivityExportRequest(request); + const status = + result.status === "ready" + ? 200 + : result.reason === "invalid_request" + ? 400 + : result.reason === "busy" + ? 503 + : 200; + return Response.json(result, { + status, + headers: { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }, + }); +} diff --git a/src/components/ui/code-editor-surface.tsx b/src/components/ui/code-editor-surface.tsx index 63c0317..a71b7d0 100644 --- a/src/components/ui/code-editor-surface.tsx +++ b/src/components/ui/code-editor-surface.tsx @@ -151,6 +151,7 @@ export function CodeEditorSurface(props: CodeEditorSurfaceProps) { const { ref, value, language = "", wrap } = props; const parentRef = useRef(null); const viewRef = useRef(null); + const detachedView = useRef<{ state: EditorState; focused: boolean } | null>(null); const latestProps = useRef(props); const compartments = useRef({ language: new Compartment(), options: new Compartment() }); @@ -161,72 +162,88 @@ export function CodeEditorSurface(props: CodeEditorSurfaceProps) { useEffect(() => { if (!parentRef.current) return; const current = latestProps.current; + const previousView = detachedView.current; + detachedView.current = null; const initialHistory = recordCodeEditorHistory(current.getInitialHistory(), current.value); const initialSource = initialHistory.entries[0] ?? current.value; const view = new EditorView({ parent: parentRef.current, - state: restoreEditorSourceHistory( - EditorState.create({ - doc: normalizeEditorLineBreaks(initialSource), - extensions: [ - ...editorSourceTracking(initialSource), - history(), - lineNumbers(), - drawSelection(), - indentOnInput(), - indentUnit.of(" "), - bracketMatching(), - sourceHighlighting, - editorTheme, - keymap.of([ - ...defaultKeymap.filter( - (binding) => binding.key !== "Tab" && binding.key !== "Shift-Tab", - ), - ...historyKeymap, - ]), - compartments.current.language.of([]), - compartments.current.options.of(editorOptions(current)), - EditorView.updateListener.of((update) => { - const source = update.state.field(editorSourceField); - if (source === update.startState.field(editorSourceField)) return; - if (source !== latestProps.current.value) latestProps.current.onChange(source); - }), - EditorView.domEventHandlers({ - paste(event, editor) { - if (latestProps.current.disabled || latestProps.current.readOnly) return false; - const source = event.clipboardData?.getData("text/plain"); - if (!source) return false; - const range = editor.state.selection.main; - editor.dispatch({ - changes: { - from: range.from, - to: range.to, - insert: normalizeEditorLineBreaks(source), - }, - selection: EditorSelection.cursor( - range.from + normalizeEditorLineBreaks(source).length, - ), - annotations: [ - exactSourceChanges.of([{ from: range.from, to: range.to, insert: source }]), - Transaction.userEvent.of("input.paste"), - ], - scrollIntoView: true, - }); - event.preventDefault(); - return true; - }, - }), - ], - }), - initialHistory, - ), + state: + previousView?.state ?? + restoreEditorSourceHistory( + EditorState.create({ + doc: normalizeEditorLineBreaks(initialSource), + extensions: [ + ...editorSourceTracking(initialSource), + history(), + lineNumbers(), + drawSelection(), + indentOnInput(), + indentUnit.of(" "), + bracketMatching(), + sourceHighlighting, + editorTheme, + keymap.of([ + ...defaultKeymap.filter( + (binding) => binding.key !== "Tab" && binding.key !== "Shift-Tab", + ), + ...historyKeymap, + ]), + compartments.current.language.of([]), + compartments.current.options.of(editorOptions(current)), + EditorView.updateListener.of((update) => { + const source = update.state.field(editorSourceField); + if (source === update.startState.field(editorSourceField)) return; + if (source !== latestProps.current.value) latestProps.current.onChange(source); + }), + EditorView.domEventHandlers({ + paste(event, editor) { + if (latestProps.current.disabled || latestProps.current.readOnly) return false; + const source = event.clipboardData?.getData("text/plain"); + if (!source) return false; + const range = editor.state.selection.main; + editor.dispatch({ + changes: { + from: range.from, + to: range.to, + insert: normalizeEditorLineBreaks(source), + }, + selection: EditorSelection.cursor( + range.from + normalizeEditorLineBreaks(source).length, + ), + annotations: [ + exactSourceChanges.of([{ from: range.from, to: range.to, insert: source }]), + Transaction.userEvent.of("input.paste"), + ], + scrollIntoView: true, + }); + event.preventDefault(); + return true; + }, + }), + ], + }), + initialHistory, + ), }); view.setTabFocusMode(true); view.scrollDOM.tabIndex = current.disabled ? -1 : 0; view.scrollDOM.setAttribute("role", "group"); view.scrollDOM.setAttribute("aria-label", current.scrollLabel); viewRef.current = view; + const ownerDocument = view.contentDOM.ownerDocument; + if ( + previousView?.focused && + current.active && + !current.disabled && + (!ownerDocument.activeElement || ownerDocument.activeElement === ownerDocument.body) + ) { + view.focus(); + } return () => { + // Strict Mode reconnects effects after the shell has already handed focus to this view. + // Preserve the exact editor state, but only restore focus if nothing else received it. + detachedView.current = { state: view.state, focused: view.hasFocus }; viewRef.current = null; view.destroy(); }; diff --git a/src/core/AGENTS.md b/src/core/AGENTS.md index d10a5fb..cb13b3f 100644 --- a/src/core/AGENTS.md +++ b/src/core/AGENTS.md @@ -1,5 +1,7 @@ # Reglas de `src/core` +- `CustomSection.title` conserva el nombre interno. `showTitle?: boolean` solo en v2 oculta el encabezado exportado con `false`; ausencia/true mantiene la salida anterior, sin defaults al recuperar. Conservar nombre/formato al ocultar, duplicar y abrir JSON; omitir errores/presupuesto del título oculto y ajustar únicamente la jerarquía implícita. El resultado efímero de comprobar tarjetas públicas no pertenece al documento ni al generador. Ver [ADR 0042](../../docs/decisions/0042-custom-section-title-visibility.md) y [ADR 0043](../../docs/decisions/0043-public-card-export-check.md). + Este ámbito contiene `ProfileDocument` v2, sus validaciones, operaciones puras, contratos de actividad y generación de README para cinco secciones fijas y secciones personalizadas repetibles. `sectionOrder` combina referencias a nueve IDs fijos heredados con referencias a `customSections`. Conocimientos, formación y actualidad están retirados; métricas aporta tarjetas a contribuciones sin sección separada. Los límites están definidos en [arquitectura](../../docs/architecture.md) y [editor](../../docs/editor.md). - Mantener TypeScript puro: sin React, Next.js, DOM, `window`, `localStorage`, `fetch`, variables de entorno ni SDKs de proveedores. diff --git a/src/core/activity/export-check.ts b/src/core/activity/export-check.ts new file mode 100644 index 0000000..72b5cd4 --- /dev/null +++ b/src/core/activity/export-check.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +export const activityExportCheckFailureReasonSchema = z.enum([ + "not_configured", + "protected", + "incompatible", + "unavailable", + "invalid_response", + "unreachable", + "timeout", + "busy", + "invalid_request", +]); + +export type ActivityExportCheckFailureReason = z.infer< + typeof activityExportCheckFailureReasonSchema +>; + +/** A temporary check of the public URLs, never part of ProfileDocument. */ +export const activityExportCheckResultSchema = z.discriminatedUnion("status", [ + z.strictObject({ status: z.literal("ready"), stale: z.boolean() }), + z.strictObject({ + status: z.literal("failed"), + reason: activityExportCheckFailureReasonSchema, + }), +]); + +export type ActivityExportCheckResult = z.infer; diff --git a/src/core/profile/custom-content.ts b/src/core/profile/custom-content.ts index c05ed3e..6e9203a 100644 --- a/src/core/profile/custom-content.ts +++ b/src/core/profile/custom-content.ts @@ -89,6 +89,7 @@ export const customBlockSchema = z.discriminatedUnion("type", [ export const customSectionSchema = z.strictObject({ id: customEntryIdSchema, title: z.string(), + showTitle: z.boolean().optional(), titleLevel: customHeadingLevelSchema.optional(), titleAlignment: customTextAlignmentSchema.optional(), visible: z.boolean(), diff --git a/src/core/readme/activity-readme.test.ts b/src/core/readme/activity-readme.test.ts index 03fe6e3..23c1cc5 100644 --- a/src/core/readme/activity-readme.test.ts +++ b/src/core/readme/activity-readme.test.ts @@ -111,6 +111,36 @@ describe("one optional activity and contributions section", () => { expect(shown.success && shown.markdown).toContain("?locale=es"); }); + it("places manual contributions before or after activity cards as selected", () => { + const profile = activityProfile(); + profile.visibility.contributions = true; + profile.data.contributions.items = [ + { id: "guide", title: "A useful guide", description: "", url: "" }, + ]; + const activityFirst = generateReadme(profile, { + activityCardBaseUrl: "https://profiles.example.com", + }); + expect(activityFirst.success).toBe(true); + if (!activityFirst.success) return; + const activity = activityFirst.markdown.indexOf("[![GitHub contribution calendar"); + const contribution = activityFirst.markdown.indexOf("### Highlights"); + expect(activity).toBeGreaterThanOrEqual(0); + expect(contribution).toBeGreaterThan(activity); + + profile.data.metrics.contentOrder = "contributions-first"; + const contributionsFirst = generateReadme(profile, { + activityCardBaseUrl: "https://profiles.example.com", + }); + expect(contributionsFirst.success).toBe(true); + if (!contributionsFirst.success) return; + const reorderedActivity = contributionsFirst.markdown.indexOf( + "[![GitHub contribution calendar", + ); + const reorderedContribution = contributionsFirst.markdown.indexOf("### Highlights"); + expect(reorderedContribution).toBeGreaterThanOrEqual(0); + expect(reorderedContribution).toBeLessThan(reorderedActivity); + }); + it("validates a username only for selected visible cards", () => { const profile = activityProfile(); profile.data.metrics.username = "https://example.com/"; diff --git a/src/core/readme/compact-technologies.test.ts b/src/core/readme/compact-technologies.test.ts index 7485cb9..d5447b0 100644 --- a/src/core/readme/compact-technologies.test.ts +++ b/src/core/readme/compact-technologies.test.ts @@ -75,7 +75,10 @@ describe("compact technologies", () => { const original = structuredClone(data); const result = generated(data); expect(result.markdown).not.toContain("### Frontend"); - expect(result.markdown).toContain("Frontend: "); + expect(result.markdown).toContain("Frontend: "); + expect(result.markdown).not.toMatch(/\s(?:style|class)=/); expect(result.markdown).toContain('alt=""'); expect(result.markdown).toContain('width="20" height="20"'); expect(result.markdown).not.toContain('width="32"'); diff --git a/src/core/readme/custom-title-visibility.test.ts b/src/core/readme/custom-title-visibility.test.ts new file mode 100644 index 0000000..ecaee39 --- /dev/null +++ b/src/core/readme/custom-title-visibility.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import type { CustomSection } from "../profile/custom-content"; +import { + createEmptyProfile, + duplicateCustomSection, + setCustomSectionVisibility, +} from "../profile/profile-operations"; +import { parseProfileDocument } from "../profile/profile-validation"; +import { parseProjectFile, serializeProjectFile } from "../project"; +import { generateReadme } from "./generate-readme"; +import { PROFILE_HTML_MAX_DOCUMENT_LENGTH, PROFILE_HTML_MAX_LENGTH } from "./profile-html"; +import { renderCustomSectionWithListIndex } from "./render-custom"; +import type { ReadmeIssue } from "./readme-result"; + +function profile(changes: Partial = {}) { + const document = createEmptyProfile("en"); + document.customSections = [ + { + id: "custom", + title: "My internal section name", + visible: true, + blocks: [{ id: "body", type: "paragraph", text: "My visible content" }], + ...changes, + }, + ]; + document.sectionOrder.push({ kind: "custom", sectionId: "custom" }); + return document; +} + +describe("custom section title visibility", () => { + it.each([undefined, true, false])( + "exports a named section with showTitle %s and preserves the authored setting", + (showTitle) => { + const document = profile(showTitle === undefined ? {} : { showTitle }); + const parsed = parseProfileDocument(document); + expect(parsed).toEqual({ success: true, data: document }); + if (!parsed.success) throw new Error("Expected a valid profile"); + expect(Object.hasOwn(parsed.data.customSections[0]!, "showTitle")).toBe( + showTitle !== undefined, + ); + expect(generateReadme(parsed.data)).toEqual({ + success: true, + markdown: + showTitle === false + ? "My visible content\n" + : "## My internal section name\n\nMy visible content\n", + }); + expect(parsed.data.customSections[0]!.title).toBe("My internal section name"); + }, + ); + + it("rejects a non-boolean visibility value without changing the input", () => { + const input = profile(); + Object.assign(input.customSections[0]!, { showTitle: "false" }); + const original = structuredClone(input); + const parsed = parseProfileDocument(input); + expect(parsed).toMatchObject({ + success: false, + errors: [{ code: "invalid_type", path: ["customSections", 0, "showTitle"] }], + }); + expect(input).toEqual(original); + }); + + it("does not validate or spend HTML budget on a hidden formatted title", () => { + const document = profile({ + title: "x".repeat(PROFILE_HTML_MAX_LENGTH + 1), + showTitle: false, + titleLevel: 1, + titleAlignment: "center", + }); + const budget = { sourceLength: PROFILE_HTML_MAX_DOCUMENT_LENGTH - 1 }; + const errors: ReadmeIssue[] = []; + const result = renderCustomSectionWithListIndex( + document.customSections[0]!, + 0, + errors, + 0, + [], + budget, + ); + expect(result).toEqual({ markdown: "My visible content", nextListIndex: 0 }); + expect(errors).toEqual([]); + expect(budget.sourceLength).toBe(PROFILE_HTML_MAX_DOCUMENT_LENGTH - 1); + expect(generateReadme(document)).toEqual({ success: true, markdown: "My visible content\n" }); + + document.customSections[0]!.showTitle = true; + expect(generateReadme(document)).toEqual({ + success: false, + errors: [{ code: "invalid_html", path: ["customSections", 0, "title"] }], + }); + }); + + it("uses the untitled hierarchy for implicit headings while preserving explicit HTML and levels", () => { + const document = profile({ + showTitle: false, + blocks: [ + { id: "implicit", type: "heading", text: "Details", alignment: "center" }, + { id: "explicit", type: "heading", text: "Chosen level", level: 3 }, + { id: "html", type: "html", source: "

Authored HTML

" }, + ], + }); + const result = generateReadme(document); + expect(result.success).toBe(true); + if (!result.success) throw new Error("Expected a generated README"); + expect(result.markdown).toBe( + '

Details

\n\n### Chosen level\n\n

Authored HTML

\n', + ); + expect(result.customHtml).toHaveLength(2); + for (const fragment of result.customHtml!) { + expect(fragment.headingLevel).toBe(3); + expect(result.markdown.slice(fragment.start, fragment.end)).toBe(fragment.html); + } + }); + + it("keeps name, title visibility and formatting through section inclusion, duplication and JSON", () => { + const document = profile({ showTitle: false, titleLevel: 1, titleAlignment: "right" }); + const hidden = setCustomSectionVisibility(document, "custom", false); + if (!hidden.success) throw new Error("Expected hidden section"); + const shown = setCustomSectionVisibility(hidden.data, "custom", true); + if (!shown.success) throw new Error("Expected included section"); + expect(shown.data).toEqual(document); + + let nextId = 0; + const duplicated = duplicateCustomSection(shown.data, "custom", () => `copy-${++nextId}`); + if (!duplicated.success) throw new Error("Expected duplicate"); + expect(duplicated.data.customSections[1]).toEqual({ + ...document.customSections[0], + id: "copy-1", + blocks: [{ id: "copy-2", type: "paragraph", text: "My visible content" }], + }); + const serialized = serializeProjectFile(duplicated.data); + if (!serialized.success) throw new Error("Expected serialized project"); + const reopened = parseProjectFile(serialized.source); + expect(reopened).toEqual({ success: true, document: duplicated.data, sourceVersion: 2 }); + if (!reopened.success) throw new Error("Expected reopened project"); + expect(generateReadme(reopened.document)).toEqual(generateReadme(duplicated.data)); + expect(document.customSections[0]!.title).toBe("My internal section name"); + }); +}); diff --git a/src/core/readme/render-custom.ts b/src/core/readme/render-custom.ts index 667f572..207a031 100644 --- a/src/core/readme/render-custom.ts +++ b/src/core/readme/render-custom.ts @@ -36,7 +36,7 @@ export function renderCustomSectionWithListIndex( ): CustomSectionRenderResult { let nextListIndex = initialListIndex; if (!section.visible) return { markdown: "", nextListIndex }; - const title = escapeInlineText(section.title); + const title = section.showTitle === false ? "" : escapeInlineText(section.title); const htmlBlocks = new Map(); function html(source: string, path: readonly (string | number)[], blockIndex: number): string { const rendered = renderProfileHtml(source, path, errors, htmlBudget); diff --git a/src/core/readme/render-technologies.ts b/src/core/readme/render-technologies.ts index 09bebbe..6ba88ba 100644 --- a/src/core/readme/render-technologies.ts +++ b/src/core/readme/render-technologies.ts @@ -52,24 +52,23 @@ export function renderTechnologies( if (!name && !context) continue; hasContent = true; if (name) { + const chipChildren: ElementContent[] = []; + if (technology) + chipChildren.push( + { + type: "element", + tagName: "img", + properties: { width: 20, height: 20, src: technology.iconUrl, alt: "" }, + children: [], + }, + { type: "text", value: " " }, + ); + chipChildren.push({ type: "text", value: name }); const chip: Element = { type: "element", tagName: "kbd", properties: {}, - children: [ - ...(technology - ? [ - { - type: "element" as const, - tagName: "img", - properties: { width: 20, height: 20, src: technology.iconUrl, alt: "" }, - children: [], - }, - { type: "text" as const, value: " " }, - ] - : []), - { type: "text", value: name }, - ], + children: chipChildren, }; paragraph.push(chip); } diff --git a/src/features/editor/activity-export-readiness.tsx b/src/features/editor/activity-export-readiness.tsx new file mode 100644 index 0000000..6b3c2e6 --- /dev/null +++ b/src/features/editor/activity-export-readiness.tsx @@ -0,0 +1,89 @@ +import { ArrowUpRight, Check, CircleAlert, LoaderCircle } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/cn"; +import type { ActivityExportCheckState } from "./use-activity-export-check"; + +export function ActivityExportReadiness({ + state, + firstSource, + disabled, + onCheck, + onExclude, +}: { + state: ActivityExportCheckState; + firstSource: string; + disabled: boolean; + onCheck: () => void; + onExclude: () => void; +}) { + const t = useTranslations("Editor.export.cards"); + const checking = state.status === "checking"; + const message = + state.status === "failed" + ? t(`errors.${state.reason}`) + : state.status === "ready" + ? t(state.stale ? "stale" : "ready") + : checking + ? t("checking") + : t("idle"); + return ( +
+

{t("title")}

+

{t("help")}

+

+ {state.status === "failed" && ( +

+
+ + +
+ {state.status === "failed" && ( + + {t("openImage")} + + )} +
+ ); +} diff --git a/src/features/editor/editor-workspace.tsx b/src/features/editor/editor-workspace.tsx index e9f3238..5b7df40 100644 --- a/src/features/editor/editor-workspace.tsx +++ b/src/features/editor/editor-workspace.tsx @@ -54,6 +54,8 @@ import { localActivityCardSources } from "./activity-card-requests"; import { useActivityData } from "./use-activity-data"; import { useEditorScroll } from "./use-editor-scroll"; import { downloadProjectFile } from "./download-project-file"; +import { useActivityExportCheck } from "./use-activity-export-check"; +import { ActivityExportReadiness } from "./activity-export-readiness"; function ImportLoading() { const t = useTranslations("Editor"); @@ -192,11 +194,11 @@ export function EditorWorkspace({ } const activeLabel = activeId ? (sectionLabels.get(activeId) ?? null) : null; const activeCustom = getCustomSection(profile, activeId); - const activeHeading = activeCustom - ? t("custom.untitled", { number: profile.customSections.indexOf(activeCustom) + 1 }) - : activeLabel; + const activeHeading = activeLabel; const isLoading = hydration === "loading"; const result = generateReadme(profile, { activityCardBaseUrl }); + const exportedCardSources = result.success ? (result.activityCards ?? []) : []; + const publicCardCheck = useActivityExportCheck(exportedCardSources); const activityCardSources = result.success ? localActivityCardSources(result.activityCards ?? [], activityCardBaseUrl) : []; @@ -228,7 +230,13 @@ export function EditorWorkspace({ const hasHtmlErrors = readmeErrors.some((error) => error.code === "invalid_html"); const hasCodeErrors = readmeErrors.some((error) => error.code === "invalid_code"); const hasLocalCards = result.success && Boolean(result.exportIssues?.length); - const canExport = !isLoading && result.success && !hasLocalCards; + const hasExportedCards = exportedCardSources.length > 0; + const canCheckCards = !isLoading && result.success && !hasLocalCards && hasExportedCards; + const canExport = + !isLoading && + result.success && + !hasLocalCards && + (!hasExportedCards || publicCardCheck.state.status === "ready"); const exportGuidance = isLoading ? t("storage.loading") : isEmpty @@ -249,7 +257,9 @@ export function EditorWorkspace({ ? t("export.invalidLinks") : hasLocalCards ? t("export.localCards") - : t("export.ready"); + : hasExportedCards && publicCardCheck.state.status !== "ready" + ? t("export.cards.required") + : t("export.ready"); useEffect(() => { const timer = window.setTimeout(() => { @@ -472,6 +482,15 @@ export function EditorWorkspace({ } } + function excludeActivityCards() { + const next = setModuleVisibility(profile, "metrics", false); + if (next.success) { + pendingWorkspaceFocus.current = true; + editDocument(next.data); + setAnnouncement(t("metrics.hidden")); + } + } + function downloadReadme() { if (!canExport || !result.success) return; let objectUrl: string | null = null; @@ -1074,19 +1093,24 @@ export function EditorWorkspace({ size="sm" className="mt-2 max-w-full text-left whitespace-normal" disabled={isLoading} - onClick={() => { - const next = setModuleVisibility(profile, "metrics", false); - if (next.success) { - pendingWorkspaceFocus.current = true; - editDocument(next.data); - setAnnouncement(t("metrics.hidden")); - } - }} + onClick={excludeActivityCards} > {t("metrics.hide")} )} + {canCheckCards && ( + { + setExportFeedback("idle"); + void publicCardCheck.check(); + }} + onExclude={excludeActivityCards} + /> + )}

({ useTranslations: () => (key: string) => key })); import { ProfileHtmlPreview } from "./profile-html-preview"; describe("profile HTML technology chips", () => { + it("keeps catalog icons and visible names inside the same exported badge", () => { + const document = createEmptyProfile("en"); + document.visibility.technologies = true; + document.data.technologies.groups = [ + { id: "web", name: "Frontend", items: [{ id: "react", name: "React", context: "" }] }, + ]; + const result = generateReadme(document); + if (!result.success || !result.customHtml?.[0]) throw new Error("Expected compact HTML"); + const html = renderToStaticMarkup( + createElement(ProfileHtmlPreview, { fragment: result.customHtml[0] }), + ); + expect(html).toMatch(/]*>]*class="[^"]*align-middle[^"]*"[^>]*\/?> React<\/kbd>/); + expect(html).not.toContain(" { const technology = findTechnology("React")!; const fragment: CustomHtmlFragment = { diff --git a/src/features/editor/profile-html-preview.tsx b/src/features/editor/profile-html-preview.tsx index ca4f78f..2570686 100644 --- a/src/features/editor/profile-html-preview.tsx +++ b/src/features/editor/profile-html-preview.tsx @@ -112,7 +112,12 @@ export function ProfileHtmlPreview({ fragment }: { fragment: CustomHtmlFragment const t = useTranslations("Editor.preview"); const headingLevels = previewHeadingLevels(fragment.tree, fragment.headingLevel); - function renderNode(node: RootContent, path: string, insideLink = false): ReactNode { + function renderNode( + node: RootContent, + path: string, + insideLink = false, + parentTag?: string, + ): ReactNode { if (node.type === "text") return node.value; if (node.type !== "element" || !allowedTags.has(node.tagName)) return null; const properties = node.properties; @@ -127,7 +132,9 @@ export function ProfileHtmlPreview({ fragment }: { fragment: CustomHtmlFragment : ""; const common = { title }; const children = (linked = insideLink) => - node.children.map((child, index) => renderNode(child, `${path}.${index}`, linked)); + node.children.map((child, index) => + renderNode(child, `${path}.${index}`, linked, node.tagName), + ); if (node.tagName === "img") { const src = @@ -149,7 +156,7 @@ export function ProfileHtmlPreview({ fragment }: { fragment: CustomHtmlFragment width={20} height={20} unoptimized - className="inline-block size-5 shrink-0 align-middle" + className={`inline-block size-5 shrink-0 ${parentTag === "sub" ? "align-baseline" : "align-middle"}`} /> ); const country = findCountryByIconUrl(src); diff --git a/src/features/editor/use-activity-export-check.ts b/src/features/editor/use-activity-export-check.ts new file mode 100644 index 0000000..8b17f86 --- /dev/null +++ b/src/features/editor/use-activity-export-check.ts @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + activityExportCheckResultSchema, + type ActivityExportCheckResult, +} from "@/core/activity/export-check"; + +export type ActivityExportCheckState = + { status: "idle" } | { status: "checking" } | ActivityExportCheckResult; + +const VERIFICATION_LIFETIME_MS = 5 * 60 * 1000; + +/** Public image availability is checked only through the explicit action. */ +export function useActivityExportCheck(sources: readonly string[]) { + const key = JSON.stringify(sources); + const [attempt, setAttempt] = useState<{ key: string; state: ActivityExportCheckState }>({ + key, + state: { status: "idle" }, + }); + const requestRef = useRef(null); + if (attempt.key !== key) { + setAttempt({ key, state: { status: "idle" } }); + } + const state: ActivityExportCheckState = attempt.key === key ? attempt.state : { status: "idle" }; + + useEffect(() => { + return () => { + requestRef.current?.abort(); + requestRef.current = null; + }; + }, [key]); + + useEffect(() => { + if (attempt.key !== key || attempt.state.status !== "ready") return; + const expiry = window.setTimeout(() => { + setAttempt((current) => (current === attempt ? { key, state: { status: "idle" } } : current)); + }, VERIFICATION_LIFETIME_MS); + return () => window.clearTimeout(expiry); + }, [attempt, key]); + + async function check() { + if (sources.length === 0) return; + requestRef.current?.abort(); + const controller = new AbortController(); + requestRef.current = controller; + setAttempt({ key, state: { status: "checking" } }); + let timedOut = false; + const timeout = window.setTimeout(() => { + timedOut = true; + controller.abort(); + }, 35_000); + try { + const response = await fetch("/api/cards/export-check", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sources }), + signal: controller.signal, + }); + const parsed = activityExportCheckResultSchema.safeParse(await response.json()); + if (controller.signal.aborted) return; + const result: ActivityExportCheckResult = + parsed.success && (response.ok || parsed.data.status === "failed") + ? parsed.data + : { status: "failed", reason: "invalid_response" }; + setAttempt({ key, state: result }); + } catch { + if (!controller.signal.aborted || timedOut) { + setAttempt({ + key, + state: { status: "failed", reason: timedOut ? "timeout" : "unreachable" }, + }); + } + } finally { + window.clearTimeout(timeout); + if (requestRef.current === controller) requestRef.current = null; + } + } + + return { state, check }; +} diff --git a/src/messages/en/editor.json b/src/messages/en/editor.json index 9fd41ee..942ed2f 100644 --- a/src/messages/en/editor.json +++ b/src/messages/en/editor.json @@ -195,6 +195,30 @@ "copy": "Copy README", "copying": "Copying…", "download": "Download README", + "cards": { + "title": "Published card images", + "help": "The preview is drawn in this browser. Images in your README need a public card host that opens without signing in.", + "required": "Check the published card images before copying or downloading your README.", + "idle": "The public image addresses have not been checked yet.", + "check": "Check public cards", + "checking": "Checking public cards…", + "checkAgain": "Check public cards again", + "ready": "The public card images responded correctly. Copy and download are available for five minutes, or until you change the cards.", + "stale": "The public card images are reachable and show cached data. Copy and download are available for five minutes, or until you change the cards.", + "exclude": "Exclude cards and keep settings", + "openImage": "Open a published card image", + "errors": { + "not_configured": "This instance needs a public card host. Ask the app operator to configure one, or exclude cards to export your writing.", + "protected": "The card host requires sign-in or redirects. Its images cannot load in a public README. Use a public production host or exclude cards.", + "incompatible": "The published card service does not support these image addresses or options yet. The app operator needs to deploy the matching version.", + "unavailable": "The public card service cannot provide these cards right now. Check its data-provider configuration or try again later.", + "invalid_response": "The card host did not return a ready SVG image. Check its hosting configuration or exclude cards.", + "unreachable": "The public card host could not be reached. Check its address and connection, then retry.", + "timeout": "The public image check timed out. The host may still be loading data; retry the check.", + "busy": "Public card checks are busy. Try again shortly.", + "invalid_request": "These generated image addresses could not be checked. Reload after app updates and try again." + } + }, "feedback": { "copied": "README copied to your clipboard.", "copyError": "We could not copy to your clipboard. Try downloading the README instead.", diff --git a/src/messages/en/modules/custom.json b/src/messages/en/modules/custom.json index 211e32a..0f34144 100644 --- a/src/messages/en/modules/custom.json +++ b/src/messages/en/modules/custom.json @@ -1,8 +1,10 @@ { "description": "A little space for whatever you want to share.", - "title": "Section title", - "titleHelp": "Optional. Your own title appears in the README; leaving it blank keeps just the content.", + "title": "Section name", + "titleHelp": "Optional. Use this name to organize the section in the editor. The checkbox below controls whether it also appears as a README heading.", "titlePlaceholder": "For example, How I like to work", + "showTitle": "Show title in README", + "showTitleHelp": "Uncheck to export only the content. The section name and title formatting stay saved in the editor.", "intro": "Choose a block below. Each small sample shows its layout before you add it; the new block starts empty.", "addContent": "Add section content", "addBlock": "Add block", @@ -134,9 +136,13 @@ "codeLabel": "Code block {number}", "code": { "language": "Language (optional)", - "languageHelp": "All suggested languages have local syntax highlighting, including json, csharp and python. Leave it blank for plain text. Colors do not validate the code; GitHub may use different colors.", - "plainLanguageHelp": "There is no local highlighter for {language}. Your code stays as plain text here and keeps its language in the README. Choose a suggestion if you want local colors.", - "languagePlaceholder": "Plain text, json, csharp…", + "languageHelp": "Choose a language for local highlighting, or select Plain text. Choose Custom to enter another identifier. GitHub may highlight it differently.", + "plainLanguageHelp": "There is no local highlighter for {language}. Your code stays as plain text in the editor, and its identifier remains in the README.", + "plainText": "Plain text", + "customLanguage": "Custom…", + "customLanguageInput": "Language identifier", + "customLanguageHelp": "Enter a short identifier of up to {limit} characters. Unknown identifiers stay as plain text in the editor.", + "languagePlaceholder": "For example, rust-analyzer", "invalidLanguage": "Use a short language name such as python, ts or c#. Spaces, Markdown fences and long identifiers are not supported.", "source": "Code", "help": "Paste only the snippet, without Markdown fences. Spaces and line breaks are kept. HTML here is shown as code, never rendered or run.", diff --git a/src/messages/es/editor.json b/src/messages/es/editor.json index 27c1180..cbd2255 100644 --- a/src/messages/es/editor.json +++ b/src/messages/es/editor.json @@ -195,6 +195,30 @@ "copy": "Copiar README", "copying": "Copiando…", "download": "Descargar README", + "cards": { + "title": "Imágenes de tarjetas publicadas", + "help": "La vista previa se dibuja en este navegador. Las imágenes del README necesitan un servidor público de tarjetas que abra sin iniciar sesión.", + "required": "Comprobá las imágenes de tarjetas publicadas antes de copiar o descargar el README.", + "idle": "Todavía no se comprobaron las direcciones públicas de las imágenes.", + "check": "Comprobar tarjetas públicas", + "checking": "Comprobando tarjetas públicas…", + "checkAgain": "Volver a comprobar tarjetas públicas", + "ready": "Las imágenes públicas respondieron correctamente. Podés copiar y descargar durante cinco minutos o hasta cambiar las tarjetas.", + "stale": "Las imágenes públicas están disponibles y muestran datos en caché. Podés copiar y descargar durante cinco minutos o hasta cambiar las tarjetas.", + "exclude": "Excluir tarjetas y conservar ajustes", + "openImage": "Abrir una imagen de tarjeta publicada", + "errors": { + "not_configured": "Esta instancia necesita un servidor público de tarjetas. Pedile a quien administra la app que lo configure, o excluí las tarjetas para exportar tus textos.", + "protected": "El servidor de tarjetas requiere iniciar sesión o redirige. Sus imágenes no pueden cargar en un README público. Usá un servidor de producción público o excluí las tarjetas.", + "incompatible": "El servicio de tarjetas publicado todavía no admite estas direcciones u opciones. Quien administra la app debe desplegar la versión correspondiente.", + "unavailable": "El servicio público no puede generar estas tarjetas ahora. Revisá su configuración de datos o probá más tarde.", + "invalid_response": "El servidor de tarjetas no devolvió una imagen SVG disponible. Revisá su configuración de hosting o excluí las tarjetas.", + "unreachable": "No se pudo acceder al servidor público de tarjetas. Revisá su dirección y conexión, y volvé a intentar.", + "timeout": "Se agotó el tiempo de comprobación. El servidor puede seguir cargando datos; volvé a comprobar.", + "busy": "La comprobación de tarjetas está ocupada. Probá de nuevo en un momento.", + "invalid_request": "No se pudieron comprobar estas direcciones generadas. Recargá después de actualizar la app y volvé a intentar." + } + }, "feedback": { "copied": "README copiado al portapapeles.", "copyError": "No pudimos copiar al portapapeles. Probá descargar el README.", diff --git a/src/messages/es/modules/custom.json b/src/messages/es/modules/custom.json index fcaf9a5..967788e 100644 --- a/src/messages/es/modules/custom.json +++ b/src/messages/es/modules/custom.json @@ -1,8 +1,10 @@ { "description": "Un espacio para lo que quieras compartir.", - "title": "Título de la sección", - "titleHelp": "Opcional. Tu título aparece en el README; si lo dejás vacío, se muestra solo el contenido.", + "title": "Nombre de la sección", + "titleHelp": "Opcional. Usá este nombre para organizar la sección en el editor. La casilla de abajo controla si también aparece como título del README.", "titlePlaceholder": "Por ejemplo, Cómo me gusta trabajar", + "showTitle": "Mostrar título en el README", + "showTitleHelp": "Desmarcá para exportar solo el contenido. El nombre y el formato del título quedan guardados en el editor.", "intro": "Elegí un bloque de abajo. Cada muestra indica su distribución antes de agregarlo; el nuevo bloque empieza vacío.", "addContent": "Agregar contenido a la sección", "addBlock": "Agregar bloque", @@ -134,9 +136,13 @@ "codeLabel": "Bloque de código {number}", "code": { "language": "Lenguaje (opcional)", - "languageHelp": "Todos los lenguajes sugeridos tienen resaltado local, incluidos json, csharp y python. Dejalo vacío para texto plano. Los colores no validan el código; GitHub puede usar otros colores.", - "plainLanguageHelp": "No hay resaltado local para {language}. Tu código se muestra como texto plano acá y conserva el lenguaje en el README. Elegí una sugerencia si querés ver colores locales.", - "languagePlaceholder": "Texto plano, json, csharp…", + "languageHelp": "Elegí un lenguaje para resaltarlo acá o seleccioná Texto plano. Elegí Personalizado para ingresar otro identificador. GitHub puede resaltarlo de otra manera.", + "plainLanguageHelp": "No hay resaltado local para {language}. Tu código se muestra como texto plano en el editor y conserva su identificador en el README.", + "plainText": "Texto plano", + "customLanguage": "Personalizado…", + "customLanguageInput": "Identificador del lenguaje", + "customLanguageHelp": "Ingresá un identificador corto de hasta {limit} caracteres. Los identificadores desconocidos se muestran como texto plano en el editor.", + "languagePlaceholder": "Por ejemplo, rust-analyzer", "invalidLanguage": "Usá un nombre corto de lenguaje como python, ts o c#. No se admiten espacios, delimitadores de Markdown ni identificadores largos.", "source": "Código", "help": "Pegá solo el fragmento, sin los delimitadores de Markdown. Se conservan espacios y saltos de línea. El HTML escrito acá se muestra como código, nunca se interpreta ni ejecuta.", diff --git a/src/modules/AGENTS.md b/src/modules/AGENTS.md index d06f45b..9bb64bf 100644 --- a/src/modules/AGENTS.md +++ b/src/modules/AGENTS.md @@ -1,5 +1,7 @@ # Reglas de `src/modules` +- La sección personalizada permite un nombre interno mediante `title` y una casilla independiente «Mostrar título en el README». `showTitle: false` conserva nombre y formato; reactivar quita el override sin borrar las opciones. Mantener el nombre en navegación y encabezado del editor aunque no se exporte. Los subtítulos implícitos consideran únicamente el título visible. Ver [ADR 0042](../../docs/decisions/0042-custom-section-title-visibility.md). + En Presentación, los ejemplos completos de saludo y titular con emojis se muestran como placeholders y ayuda. Los controles de tipografía por campo ofrecen predeterminado de GitHub, cursiva y monoespaciado; no prometer familias de fuentes que GitHub no admite. Elegir predeterminado elimina esa opción, sin modificar texto, alineación ni tamaño. Este ámbito contiene los cinco formularios fijos de presentación, tecnologías, proyectos, actividad y contribuciones, y contacto, además del formulario para instancias personalizadas. Conocimientos, formación y actualidad siguen retirados, también en borradores anteriores. Métricas se configura dentro de contribuciones y no tiene navegación separada. Consultar [arquitectura](../../docs/architecture.md), [editor](../../docs/editor.md) y el alcance autorizado antes de ampliar sus funciones. diff --git a/src/modules/custom/code-block-fields.tsx b/src/modules/custom/code-block-fields.tsx index 3782814..3328a59 100644 --- a/src/modules/custom/code-block-fields.tsx +++ b/src/modules/custom/code-block-fields.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { CodeEditor, type CodeEditorHandle } from "@/components/ui/code-editor"; @@ -6,12 +7,16 @@ import { Input } from "@/components/ui/input"; import { codeLanguages, CODE_MAX_DOCUMENT_LENGTH, + CODE_MAX_LANGUAGE_LENGTH, CODE_MAX_SOURCE_LENGTH, normalizeCodeLanguage, } from "@/core/code"; import type { CustomBlock } from "@/core/profile/custom-content"; type CodeBlock = Extract; +const CUSTOM_LANGUAGE = "__custom_language__"; +const languageSelectClassName = + "min-h-11 w-full min-w-0 rounded-md border border-border-strong bg-surface px-3 py-2 text-base text-foreground focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:outline-none sm:text-sm"; type CodeBlockFieldsProps = { controlId: string; @@ -19,7 +24,7 @@ type CodeBlockFieldsProps = { inputRef: (control: CodeEditorHandle | null) => void; hasSourceError: boolean; onChange: (value: CodeBlock) => void; - onUseExample: (source: string) => void; + onUseExample: (source: string) => boolean; }; export function CodeBlockFields({ @@ -33,6 +38,11 @@ export function CodeBlockFields({ const t = useTranslations("EditorModules.custom"); const language = normalizeCodeLanguage(value.language); const hasLocalHighlighting = codeLanguages.some((entry) => entry.id === language); + const [customLanguageFor, setCustomLanguageFor] = useState(null); + const hasCustomLanguage = + language === null || + (language !== "" && !hasLocalHighlighting) || + customLanguageFor === value.language; const tooLong = value.source.length > CODE_MAX_SOURCE_LENGTH; const exampleSource: string = t.raw("code.exampleSource"); return ( @@ -40,29 +50,59 @@ export function CodeBlockFields({ - onChange({ ...value, language: event.target.value })} - /> + - - {codeLanguages.map((entry) => ( - + {hasCustomLanguage && ( + + { + setCustomLanguageFor(event.target.value); + onChange({ ...value, language: event.target.value }); + }} + /> + + )} onUseExample(exampleSource)} + onClick={() => { + if (onUseExample(exampleSource)) setCustomLanguageFor(null); + }} > {t("code.useExample")} diff --git a/src/modules/custom/custom-section-form.tsx b/src/modules/custom/custom-section-form.tsx index d49c0ed..ca255ac 100644 --- a/src/modules/custom/custom-section-form.tsx +++ b/src/modules/custom/custom-section-form.tsx @@ -4,6 +4,7 @@ import { useEffect, useId, useRef, useState } from "react"; import { AlignLeft, ArrowDown, ArrowUp, ChevronDown, Plus, Trash2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { CodeEditor, type CodeEditorHandle } from "@/components/ui/code-editor"; import { Field } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; @@ -25,6 +26,7 @@ import { changeCustomTextKind, changeCustomTitleAlignment, changeCustomTitleLevel, + changeCustomTitleVisibility, getCustomTextKind, } from "./custom-text-formatting"; @@ -339,6 +341,7 @@ export function CustomSectionForm({ value, errors, onChange }: CustomSectionForm label={t("title")} description={t("titleHelp")} error={ + value.showTitle !== false && errors.some( (error) => error.code === "invalid_html" && @@ -356,14 +359,40 @@ export function CustomSectionForm({ value, errors, onChange }: CustomSectionForm onChange={(event) => onChange({ ...value, title: event.target.value })} /> - onChange(changeCustomTitleLevel(value, level))} - onAlignmentChange={(alignment) => onChange(changeCustomTitleAlignment(value, alignment))} - /> +

+ + onChange(changeCustomTitleVisibility(value, checked === true)) + } + /> +
+ +

+ {t("showTitleHelp")} +

+
+
+ {value.showTitle !== false && ( + onChange(changeCustomTitleLevel(value, level))} + onAlignmentChange={(alignment) => onChange(changeCustomTitleAlignment(value, alignment))} + /> + )}