From 096518c2f78a736f2314b099fe987f698056c9d4 Mon Sep 17 00:00:00 2001 From: Juan Nicolas Herrera <54152074+juanNH@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:12:39 -0300 Subject: [PATCH] feat: refine activity and contribution layout --- AGENTS.md | 4 +- README.md | 6 +- docs/README.md | 111 ++++++------ docs/architecture.md | 2 +- docs/assets/README.md | 14 +- .../0039-contribution-details-and-order.md | 8 +- docs/decisions/0040-activity-card-columns.md | 4 +- ...y-contribution-order-and-readable-cards.md | 26 +++ docs/editor.md | 8 +- docs/product.md | 2 +- docs/profile-model.md | 26 +-- src/app/cards/extended/[card]/route.ts | 6 +- .../cards/github/[username]/[card]/route.ts | 12 +- src/core/AGENTS.md | 4 +- src/core/activity/activity-urls.test.ts | 17 +- src/core/activity/card-appearance.ts | 16 +- src/core/activity/card-options.ts | 20 +- src/core/activity/index.ts | 6 +- src/core/activity/render-card.ts | 126 +++++++++---- src/core/activity/render-extended-profile.ts | 48 +++-- src/core/activity/render-resource-card.ts | 69 +++++-- src/core/activity/resource-urls.ts | 8 +- src/core/profile/contributions-schema.test.ts | 8 +- src/core/profile/module-content.ts | 7 +- src/core/readme/render-activity.ts | 15 +- src/core/readme/render-contributions.test.ts | 9 +- src/core/readme/render-contributions.ts | 70 +++---- src/features/editor/activity-data-cache.ts | 3 + src/messages/en/modules/contributions.json | 24 +-- src/messages/es/modules/contributions.json | 24 +-- src/modules/AGENTS.md | 4 +- .../contribution-items-editor.tsx | 171 +++++++----------- .../contributions/contributions-form.tsx | 27 +++ 33 files changed, 540 insertions(+), 365 deletions(-) create mode 100644 docs/decisions/0041-activity-contribution-order-and-readable-cards.md diff --git a/AGENTS.md b/AGENTS.md index a4b453c..4da1f6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,8 +41,8 @@ The current release includes the Next.js foundation, a bilingual visual kit, `Pr - Do not reintroduce skills, education, or current activity as options, legacy forms, or README output. Preserve their internal v1 structures and positions while editing or reordering the five active sections. Structural validation still applies to the complete document, but the generator does not validate links or content in retired modules. - Distinguish selection, visibility, and deletion: configuring a section does not hide it; hiding preserves its data; removing an entry explicitly deletes it. The checkbox changes inclusion only and preserves focus; adding and editing a hidden section are identified actions. Keep the active selection, mobile navigation, and dragging as ephemeral state outside the document. When reordering included sections, preserve hidden, retired, and metrics references in their positions. Use keys that distinguish fixed references from custom ones; confirm the order only after a valid move completes and preserve it when a move is canceled. - Activity and contributions use the fixed `contributions` reference and effective inclusion `visibility.contributions || visibility.metrics`; do not offer metrics as a separate fixed section. Hiding it from navigation turns off both flags; showing it again enables contributions and enables metrics only if selected cards remain. Recovering a draft does not rewrite flags. Keep every card type disabled initially, the panel collapsible, manual contributions available without a username, and suggestions as explicit choices. -- V2 contribution items add optional `links` (up to five), `images` (up to three public HTTPS images with non-empty alt text), and a manually entered GitHub `repository` preview to the legacy `url` field. Use `url` when `links` is absent; do not rewrite v1 or add fields on recovery. Preserve contribution order and offer explicit Move up/Move down actions. Repository previews never fetch metadata. Derived image/repository markup uses the shared filtered HTML budget and exact `customHtml` ranges, not `customImages`. See [ADR 0039](docs/decisions/0039-contribution-details-and-order.md). -- Save only `metrics.username/cards/appearance?/extended?/cardColumns?` and the chosen contributions. V2 adds `extended-repository`, `extended-gist`, and `extended-wakatime` to its seven previous IDs; v1 keeps its original three IDs. `cardColumns` is optional, limited to 1 or 2, and defaults to 2 only when creating a new profile; absence after recovery or migration keeps the previous one-card-per-row output. Preserve selected card order and place an odd final card in its own row. Preview the same GFM table Markdown with `remark-gfm`. Appearance combines presets, 79 themes, and validated colors/dimensions, with no CSS or URLs. `extended` limits statistics, filters, languages, twelve repositories/twelve Gists, and a separate WakaTime username. Do not insert defaults during recovery; reset removes appearance only. Snapshots are ephemeral, validated, and associated with identity/options; distinguish zero, partial, and absent data. «Buscar datos», below the username, works without cards and fetches common data. Afterwards, choosing components for the searched account reuses coverage and queries only missing data, with a 300 ms debounce; valid applied resources may also be loaded after Search. An identity that has not been searched requires another explicit action. Customizing data already covered renders local SVG with the same pure renderers, without HTTP SVG or JSON. Do not query on mount, recovery, opening, or entering the username. Progressive loading, errors, and retry respect reduced motion and do not loop on failures. Preview Blobs are internal and revocable and do not extend image or HTML support. Replacing or clearing the profile cancels and clears the cache and activation state. Public cache data may be persisted temporarily in separate IndexedDB storage under ADR 0031; never in `ProfileDocument`, Markdown, or backups. Optional contribution search is independent. See [ADR 0024](docs/decisions/0024-extended-public-suite-and-mit.md), [ADR 0027](docs/decisions/0027-browser-activity-data-cache.md), and [ADR 0029](docs/decisions/0029-account-first-activity-search.md). +- V2 contribution items add optional `links` (up to ten), `images` (up to three public HTTPS images with non-empty alt text), and retain legacy `repository` preview data only for compatibility. Use `url` when `links` is absent; do not rewrite v1 or migrate fields on recovery. Preserve contribution order and offer explicit Move up/Move down actions. Render old repository details as an ordinary link and description; do not offer a separate preview or fetch metadata. Derived image markup uses the shared filtered HTML budget and exact `customHtml` ranges, not `customImages`. See [ADR 0039](docs/decisions/0039-contribution-details-and-order.md). +- Save only `metrics.username/cards/appearance?/extended?/cardColumns?/contentOrder?` and the chosen contributions. V2 adds `extended-repository`, `extended-gist`, and `extended-wakatime` to its seven previous IDs; v1 keeps its original three IDs. `cardColumns` is optional, limited to 1 or 2, and defaults to 2 only when creating a new profile; absence after recovery or migration keeps the previous one-card-per-row output. Preserve selected card order and place an odd final card in its own row. In two-column mode, make the same local/public SVG renderers increase text size. `contentOrder` chooses whether activity or manual contributions appear first; absence preserves activity-first order. Preview the same GFM table Markdown with `remark-gfm`. Appearance combines presets, 79 themes, and validated colors/dimensions, with no CSS or URLs. `extended` limits statistics, filters, languages, twelve repositories/twelve Gists, and a separate WakaTime username. Do not insert defaults during recovery; reset removes appearance only. Snapshots are ephemeral, validated, and associated with identity/options; distinguish zero, partial, and absent data. «Buscar datos», below the username, works without cards and fetches common data. Afterwards, choosing components for the searched account reuses coverage and queries only missing data, with a 300 ms debounce; valid applied resources may also be loaded after Search. An identity that has not been searched requires another explicit action. Customizing data already covered renders local SVG with the same pure renderers, without HTTP SVG or JSON. Do not query on mount, recovery, opening, or entering the username. Progressive loading, errors, and retry respect reduced motion and do not loop on failures. Preview Blobs are internal and revocable and do not extend image or HTML support. Replacing or clearing the profile cancels and clears the cache and activation state. Public cache data may be persisted temporarily in separate IndexedDB storage under ADR 0031; never in `ProfileDocument`, Markdown, or backups. Optional contribution search is independent. See [ADR 0024](docs/decisions/0024-extended-public-suite-and-mit.md), [ADR 0027](docs/decisions/0027-browser-activity-data-cache.md), and [ADR 0029](docs/decisions/0029-account-first-activity-search.md). - The five Extended families adapt pinned MIT code and render locally through the app's own public clients, with no hosted chart service. Preserve notices and licenses. Panorama distinguishes the period from the language sample; monthly activity sums UTC days and identifies partial months. Do not aggregate metrics with no data. The authorized ranking is optional and disabled initially: it uses Extended's formula with all six inputs present, does not assess skill or represent an observed population percentile, and is hidden when data is null or partial. Do not infer levels. Validate routes and options exactly; allow only bounded hexadecimal colors and gradients, with no CSS, animations, hosts, or free-form parameters. See the [compatibility matrix](docs/github-stats-extended.md). - The server accepts a dedicated classic PAT and verifies that `x-oauth-scopes` is present, empty, or exactly `public_repo` before fetching data. Prefer only `public_repo` for GraphQL queries of public repositories; do not add `repo`, `read:user`, or other scopes. Reject credentials that cannot be verified, including fine-grained tokens. The client performs read-only queries even though the public scope permits other operations. Keep `GITHUB_TOKEN` out of Git and the browser. `APP_URL` is a configured public HTTPS origin, never derived from Host. If it is empty, only Vercel with `VERCEL_ENV=production` may resolve `VERCEL_PROJECT_PRODUCTION_URL` as a bare HTTPS domain under the same policy; an explicit, non-empty invalid value does not enable a fallback. Do not use `VERCEL_URL`, branch aliases, or request data. Without a valid origin, `generateReadme` allows reviewing the same Markdown with local paths and returns `exportIssues: activity_not_configured`; copy and download remain disabled until cards are deselected or a public host is configured. See [ADR 0010](docs/decisions/0010-github-activity.md) and [ADR 0036](docs/decisions/0036-production-card-origin.md). - Offer technologies through the curated local catalog, autocomplete, icon chips, and a custom-text alternative. Suggest editable categories when adding an item, honor the selected destination, and do not regroup drafts during recovery or language changes. Organizing by category is an explicit action; removing a heading preserves its tools. Preserve legacy structures, opaque IDs, order, and contexts. Icons come from Devicon, Simple Icons, and Lobe Icons pinned to commits, with local SVGs and attribution. Export decorative icons at 20 × 20 beside the visible name and keep preview compatibility with the previous 32 × 32 markup. An AI tool in the catalog does not imply AI integration. diff --git a/README.md b/README.md index d04ddd4..9f5d3a1 100644 --- a/README.md +++ b/README.md @@ -20,16 +20,16 @@ A short profile can tell a story. Every section is optional: start with a few wo | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Who you are** | A centered intro with writing examples, an optional country line, and italic or monospace title styles. | | **What you build with** | A searchable technology catalog with icons, editable categories and custom names. | -| **What you do** | Projects and contributions with editable order, multiple links, public images and a manual repository preview. | +| **What you do** | Projects and contributions with editable order, up to ten links per contribution, and public images. | | **Your own content** | Repeatable sections with text, lists, links, headings, quotes, dividers, public images, filtered HTML and syntax-highlighted code. | | **Where to find you** | Social profiles, a portfolio, websites and email. | | **Public activity, if you choose it** | Ten optional card types with themes, colors and a one- or two-card row layout; they require the configuration described [below](#optional-activity-and-cards). | Reorder sections by dragging, using the keyboard or pressing the arrow buttons. Hide a section while keeping its content. On desktop, editing and preview appear side by side; on smaller screens, switch between them. Light and dark themes are available. -![English Activity and contributions editor showing manual contribution order and links, with the preview displaying a public image and repository link with its description.](docs/assets/activity-contributions.jpg) +![English Activity and contributions editor showing manual contribution order and multiple links beside the live README preview.](docs/assets/activity-contributions.jpg) -_Contributions stay manual and editable. The repository preview uses the entered URL and description; it does not query GitHub._ +_Contributions stay manual and editable. Add repository URLs as ordinary links and include any context in the contribution description._ ## How it works diff --git a/docs/README.md b/docs/README.md index efc6f94..808981e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,60 +4,61 @@ The [toolchain and compatibility decision](decisions/0002-toolchain.md) records The GitHub Profile Generator documentation describes the product, its available behavior, and the technical guides for contributors. Development planning remains private and is not needed to set up the project or propose a contribution. -| Document | Purpose | -| ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| [Product](product.md) | Short profiles, five fixed sections, custom sections, and the profile creation flow. | -| [Development](development.md) | Environment setup, commands, routes, and validation records. | -| [Architecture](architecture.md) | Responsibilities, boundaries, and optional public GitHub activity. | -| [Profile model](profile-model.md) | The v2 contract, mixed section order, v1 compatibility, and core operations. | -| [Editor](editor.md) | Visual blocks, HTML, code, formatting, imports, and draft backups. | -| [Extended public suite](github-stats-extended.md) | Card families, counters, options, opt-in ranking, and compatibility limits. | -| [Open source](open-source.md) | MIT, attribution, contributions, inventory, and distribution preparation. | -| [Branching and pull requests](branching.md) | Branch and commit naming, forks, and integration workflow. | -| [Repository guardrails](repository-guardrails.md) | Branch validation in CI and the limits of available enforcement. | -| [Design system](design-system.md) | Internal tooling, tokens, components, and accessibility criteria. | -| [AI-assisted development](ai-development.md) | Context, delegation, and review of AI-assisted changes. | -| [Initial architecture decision](decisions/0001-architecture.md) | Context and consequences of the foundational architecture decisions. | -| [Profile document decision](decisions/0003-profile-document.md) | Zod schemas, the draft JSON shape, and strict validation. | -| [Manual profile composition](decisions/0005-manual-profile-composition.md) | Historical composition decision; current scope is defined in ADR 0008. | -| [Editor navigation](decisions/0006-editor-navigation.md) | Stable configuration, selection, inclusion, and drag-based ordering. | -| [Short profile](decisions/0007-simple-profile.md) | Catalog and icons; its section selection was superseded by ADR 0008. | -| [Five sections](decisions/0008-focused-profile.md) | Retirement of career-history modules and internal v1 compatibility. | -| [Profile icons](decisions/0009-profile-icons.md) | Expanded catalogs, contact by domain, and permitted generated images. | -| [GitHub activity](decisions/0010-github-activity.md) | Five sections, first-party cards, public limits, and configured export. | -| [Countries and contacts](decisions/0011-country-and-contact-inputs.md) | Optional countries with flags and social links configured per entry. | -| [Technology categories](decisions/0012-technology-categories.md) | A 358-item tools catalog, editable categories, and icons with labels. | -| [Custom sections](decisions/0013-custom-sections.md) | Text, list, and link blocks; independent copies; and v1-to-v2 migration. | -| [Custom visual blocks](decisions/0014-custom-visual-blocks.md) | Additive v2 blocks, public HTTPS images, and the preview allowlist. | -| [Explicit HTML](decisions/0015-explicit-profile-html.md) | Preserved source, filtered HTML, limits, and preview of the same Markdown. | -| [Top navigation and tools](decisions/0016-top-navigation-and-internal-tools.md) | Collapsible organization, mobile access to fields, and a development-only kit. | -| [Horizontal section row](decisions/0017-horizontal-section-navigation.md) | Persistent sections, horizontal ordering, and scrolling within the row. | -| [Import with backup](decisions/0018-readme-import-with-backup.md) | Local parsing, review, compatible HTML, and explicit recoverable replacement. | -| [Custom code and formatting](decisions/0019-code-and-formatting.md) | Literal source, languages, local editor, and alignment with permitted derived HTML. | -| [Presentation formatting](decisions/0020-presentation-formatting.md) | Optional levels and alignment, v1/v2 compatibility, and shared derived HTML. | -| [Project files](decisions/0021-project-files-and-recovery.md) | Complete JSON portability, review, and a backup independent of the previous profile. | -| [Owner activity](decisions/0022-token-owner-public-activity.md) | Queries with the owner's token, validated identity, and public-data filters. | -| [Customizable cards](decisions/0023-customizable-activity-cards.md) | Seven local cards, visual presets, and MIT adaptations of GitHub Stats Extended. | -| [Extended suite and MIT](decisions/0024-extended-public-suite-and-mit.md) | Ten card types, public data, opt-in ranking, and distribution notices. | -| [Activity data cache and batching](decisions/0025-activity-data-cache-and-preview-batching.md) | Incremental public-data coverage and image requests after a short pause. | -| [Explicit card loading and local samples](decisions/0026-explicit-card-loading-and-local-samples.md) | Local samples, ephemeral approval, and language selection above the form. | -| [Temporary browser data](decisions/0027-browser-activity-data-cache.md) | Coverage-based grouped searches, local rendering, and customization without queries. | -| [Independent scrolling](decisions/0028-independent-editor-scroll.md) | Two native desktop panels and natural flow on small screens. | -| [Search before choosing cards](decisions/0029-account-first-activity-search.md) | Explicit common-data prefetch and automatic loading of missing data for the searched account. | -| [Prefetch all counters](decisions/0030-all-profile-counters-prefetch.md) | Prepare all 18 counters in the current scope before customization. | -| [Persistent browser cache](decisions/0031-browser-persistent-activity-cache.md) | Temporary IndexedDB storage to reuse public data after reload without another query. | -| [API and hosting](github-api-and-hosting.md) | Shared PAT quota, caches, and Vercel Hobby conditions for public traffic. | -| [Vercel deployment](vercel-deployment.md) | Hobby deployment with a PAT, main/dev, and anonymous README-card checks. | -| [Vercel Hobby with anonymous PAT](decisions/0032-vercel-hobby-anonymous-pat.md) | Initial deployment decision, function duration, and validation limits. | -| [Project showcase](decisions/0033-project-showcase.md) | Linked title, optional v2 cover image, and composition with validated derived HTML. | -| [Presentation and location](decisions/0034-friendly-presentation.md) | Visible location, editable headline, and compatible styles. | -| [Compact technologies](decisions/0035-compact-technologies.md) | Catalog labels, inline categories, and draft compatibility. | -| [Stable card origin](decisions/0036-production-card-origin.md) | `APP_URL` priority and a fallback to the stable domain in Vercel Production only. | -| [Project descriptions and links](decisions/0037-project-description-and-links.md) | Description text, cover image, and a single URL for the title. | -| [Production indexing](decisions/0038-production-indexing-and-canonicals.md) | Environment-based indexing, localized canonicals, and public sitemap routes. | -| [Detailed contributions](decisions/0039-contribution-details-and-order.md) | Multiple links, public images, manual repository previews, and explicit contribution order. | -| [Activity card columns](decisions/0040-activity-card-columns.md) | Optional one- or two-column card rows and legacy output behavior. | -| [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. | +| Document | Purpose | +| ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| [Product](product.md) | Short profiles, five fixed sections, custom sections, and the profile creation flow. | +| [Development](development.md) | Environment setup, commands, routes, and validation records. | +| [Architecture](architecture.md) | Responsibilities, boundaries, and optional public GitHub activity. | +| [Profile model](profile-model.md) | The v2 contract, mixed section order, v1 compatibility, and core operations. | +| [Editor](editor.md) | Visual blocks, HTML, code, formatting, imports, and draft backups. | +| [Extended public suite](github-stats-extended.md) | Card families, counters, options, opt-in ranking, and compatibility limits. | +| [Open source](open-source.md) | MIT, attribution, contributions, inventory, and distribution preparation. | +| [Branching and pull requests](branching.md) | Branch and commit naming, forks, and integration workflow. | +| [Repository guardrails](repository-guardrails.md) | Branch validation in CI and the limits of available enforcement. | +| [Design system](design-system.md) | Internal tooling, tokens, components, and accessibility criteria. | +| [AI-assisted development](ai-development.md) | Context, delegation, and review of AI-assisted changes. | +| [Initial architecture decision](decisions/0001-architecture.md) | Context and consequences of the foundational architecture decisions. | +| [Profile document decision](decisions/0003-profile-document.md) | Zod schemas, the draft JSON shape, and strict validation. | +| [Manual profile composition](decisions/0005-manual-profile-composition.md) | Historical composition decision; current scope is defined in ADR 0008. | +| [Editor navigation](decisions/0006-editor-navigation.md) | Stable configuration, selection, inclusion, and drag-based ordering. | +| [Short profile](decisions/0007-simple-profile.md) | Catalog and icons; its section selection was superseded by ADR 0008. | +| [Five sections](decisions/0008-focused-profile.md) | Retirement of career-history modules and internal v1 compatibility. | +| [Profile icons](decisions/0009-profile-icons.md) | Expanded catalogs, contact by domain, and permitted generated images. | +| [GitHub activity](decisions/0010-github-activity.md) | Five sections, first-party cards, public limits, and configured export. | +| [Countries and contacts](decisions/0011-country-and-contact-inputs.md) | Optional countries with flags and social links configured per entry. | +| [Technology categories](decisions/0012-technology-categories.md) | A 358-item tools catalog, editable categories, and icons with labels. | +| [Custom sections](decisions/0013-custom-sections.md) | Text, list, and link blocks; independent copies; and v1-to-v2 migration. | +| [Custom visual blocks](decisions/0014-custom-visual-blocks.md) | Additive v2 blocks, public HTTPS images, and the preview allowlist. | +| [Explicit HTML](decisions/0015-explicit-profile-html.md) | Preserved source, filtered HTML, limits, and preview of the same Markdown. | +| [Top navigation and tools](decisions/0016-top-navigation-and-internal-tools.md) | Collapsible organization, mobile access to fields, and a development-only kit. | +| [Horizontal section row](decisions/0017-horizontal-section-navigation.md) | Persistent sections, horizontal ordering, and scrolling within the row. | +| [Import with backup](decisions/0018-readme-import-with-backup.md) | Local parsing, review, compatible HTML, and explicit recoverable replacement. | +| [Custom code and formatting](decisions/0019-code-and-formatting.md) | Literal source, languages, local editor, and alignment with permitted derived HTML. | +| [Presentation formatting](decisions/0020-presentation-formatting.md) | Optional levels and alignment, v1/v2 compatibility, and shared derived HTML. | +| [Project files](decisions/0021-project-files-and-recovery.md) | Complete JSON portability, review, and a backup independent of the previous profile. | +| [Owner activity](decisions/0022-token-owner-public-activity.md) | Queries with the owner's token, validated identity, and public-data filters. | +| [Customizable cards](decisions/0023-customizable-activity-cards.md) | Seven local cards, visual presets, and MIT adaptations of GitHub Stats Extended. | +| [Extended suite and MIT](decisions/0024-extended-public-suite-and-mit.md) | Ten card types, public data, opt-in ranking, and distribution notices. | +| [Activity data cache and batching](decisions/0025-activity-data-cache-and-preview-batching.md) | Incremental public-data coverage and image requests after a short pause. | +| [Explicit card loading and local samples](decisions/0026-explicit-card-loading-and-local-samples.md) | Local samples, ephemeral approval, and language selection above the form. | +| [Temporary browser data](decisions/0027-browser-activity-data-cache.md) | Coverage-based grouped searches, local rendering, and customization without queries. | +| [Independent scrolling](decisions/0028-independent-editor-scroll.md) | Two native desktop panels and natural flow on small screens. | +| [Search before choosing cards](decisions/0029-account-first-activity-search.md) | Explicit common-data prefetch and automatic loading of missing data for the searched account. | +| [Prefetch all counters](decisions/0030-all-profile-counters-prefetch.md) | Prepare all 18 counters in the current scope before customization. | +| [Persistent browser cache](decisions/0031-browser-persistent-activity-cache.md) | Temporary IndexedDB storage to reuse public data after reload without another query. | +| [API and hosting](github-api-and-hosting.md) | Shared PAT quota, caches, and Vercel Hobby conditions for public traffic. | +| [Vercel deployment](vercel-deployment.md) | Hobby deployment with a PAT, main/dev, and anonymous README-card checks. | +| [Vercel Hobby with anonymous PAT](decisions/0032-vercel-hobby-anonymous-pat.md) | Initial deployment decision, function duration, and validation limits. | +| [Project showcase](decisions/0033-project-showcase.md) | Linked title, optional v2 cover image, and composition with validated derived HTML. | +| [Presentation and location](decisions/0034-friendly-presentation.md) | Visible location, editable headline, and compatible styles. | +| [Compact technologies](decisions/0035-compact-technologies.md) | Catalog labels, inline categories, and draft compatibility. | +| [Stable card origin](decisions/0036-production-card-origin.md) | `APP_URL` priority and a fallback to the stable domain in Vercel Production only. | +| [Project descriptions and links](decisions/0037-project-description-and-links.md) | Description text, cover image, and a single URL for the title. | +| [Production indexing](decisions/0038-production-indexing-and-canonicals.md) | Environment-based indexing, localized canonicals, and public sitemap routes. | +| [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. | +| [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. | Start with the product guide to understand the proposal. To work on the code, follow the development guide, review the architecture, and agree on a concrete contribution goal. diff --git a/docs/architecture.md b/docs/architecture.md index 29e7a4a..ad55689 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -60,7 +60,7 @@ Top navigation retains the active section and always displays sections in a sing Activity and contributions use the `contributions` slot. Effective inclusion is `visibility.contributions || visibility.metrics`; there is no sixth section. Recovery does not modify flags. Hiding through the global checkbox turns both off; showing again enables contributions and metrics if cards remain selected. Manual contributions respect their flag, and cards respect the metrics flag. The generator composes both sources in the contributions position and never renders metrics separately. Its internal position is retained when reordering. -Contribution items preserve their array order and can be moved with explicit form controls. V2 may add multiple links, public HTTPS images with alternative text, and a manual GitHub repository URL/description preview to each item; repository metadata is not fetched. Activity `cardColumns` is an optional v2 value and changes only the GFM layout, not card selection order. The generator uses the same Markdown and validated derived-HTML metadata consumed by the preview. +Contribution items preserve their array order and can be moved with explicit form controls. V2 may add up to ten links and three public HTTPS images with alternative text. Older v2 repository preview data remains readable and is emitted as an ordinary link and description; the editor no longer offers a separate repository control. Activity `cardColumns` and `contentOrder` are optional v2 values: columns choose the GFM layout and card text sizing, while content order chooses whether activity or manual contributions appear first. The generator uses the same Markdown and validated derived-HTML metadata consumed by the preview. Drag-and-drop uses `@dnd-kit/react` and `@dnd-kit/dom` 0.5.1 in the client navigation boundary. Only the handle starts an operation through pointer or keyboard sensors; writing, checking a box, or selecting the name does not start a drag. The touch-gesture restriction applies only to the handle. During the gesture, React presents a temporary projection of the sequence; the library's optimistic DOM sorting is disabled so that projection can be discarded on cancellation or drop outside. A cancelled drag does not change the document or final list order; a valid move commits the order when it ends. Move up/Move down buttons remain available, with disabled endpoints and position announcements. Instructions and announcements are translated into EN/ES. The library does not replace review of focus, navigation, and accessibility of the flow. diff --git a/docs/assets/README.md b/docs/assets/README.md index b1e2b58..b4de5b0 100644 --- a/docs/assets/README.md +++ b/docs/assets/README.md @@ -2,13 +2,13 @@ These assets show the current GitHub Profile Generator interface. They were captured in English on October 23, 2026, using the light theme and fictional data from the [demo project](../examples/demo-profile.project.json). They contain no credentials, real account data or activity queries. Activity card examples use local decorative samples when shown. -| Asset | Content | -| -------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| [editor-overview.jpg](editor-overview.jpg) | Technology order controls and aligned compact tags in the live README preview. | -| [activity-contributions.jpg](activity-contributions.jpg) | Contribution ordering, multiple links, public image and manual repository preview. | -| [activity-card-layout.jpg](activity-card-layout.jpg) | One/two card row choices and their live GFM table layout using local samples. | -| [project-files.jpg](project-files.jpg) | The current project JSON import dialog and its local review safeguards. | -| [readme-import-review.jpg](readme-import-review.jpg) | Detected Markdown sections and conversion notices before confirming an import. | +| Asset | Content | +| -------------------------------------------------------- | ------------------------------------------------------------------------------- | +| [editor-overview.jpg](editor-overview.jpg) | Technology order controls and aligned compact tags in the live README preview. | +| [activity-contributions.jpg](activity-contributions.jpg) | Contribution editing, ordinary links, and a public image in the README preview. | +| [activity-card-layout.jpg](activity-card-layout.jpg) | One/two card row choices and their live GFM table layout using local samples. | +| [project-files.jpg](project-files.jpg) | The current project JSON import dialog and its local review safeguards. | +| [readme-import-review.jpg](readme-import-review.jpg) | Detected Markdown sections and conversion notices before confirming an import. | The main README uses static screenshots and written instructions so the product flow stays available without animation. diff --git a/docs/decisions/0039-contribution-details-and-order.md b/docs/decisions/0039-contribution-details-and-order.md index b8d18eb..80f4cc0 100644 --- a/docs/decisions/0039-contribution-details-and-order.md +++ b/docs/decisions/0039-contribution-details-and-order.md @@ -5,18 +5,18 @@ ## Context -Manual contributions previously held one optional URL and appeared in their saved order without controls to change it. People need to describe work with several destinations, an illustrative image, or a repository reference, and to choose which contribution appears first. These details must remain optional and must not require a GitHub account or API request. +Manual contributions previously held one optional URL and appeared in their saved order without controls to change it. People need to describe work with several destinations or an illustrative image, and to choose which contribution appears first. These details must remain optional and must not require a GitHub account or API request. ## Decision -Extend only the v2 contribution item with optional `links` (up to five entries), `images` (up to three public HTTPS URLs with alternative text), and `repository` (a GitHub repository URL and a description written by the user). Keep the existing `url` field for v1 compatibility. When `links` is absent, generation uses the legacy URL; when it is present, it is authoritative. V1 remains strict and migration does not insert the new properties. +Extend only the v2 contribution item with optional `links` (up to ten entries) and `images` (up to three public HTTPS URLs with alternative text). Keep the existing `url` field for compatibility. When `links` is absent, generation uses the legacy URL; when it is present, it is authoritative. The optional v2 `repository: { url, description }` field is retained only to parse drafts made by the former repository preview. It renders as a normal link and contribution description; the first edit moves it into `links` and `description`. V1 remains strict and recovery does not insert or migrate fields. -The editor adds, removes, edits, and reorders contributions with explicit keyboard-accessible move controls. Images and repository descriptions are rendered through the existing filtered derived-HTML policy and shared budget, with exact `customHtml` ranges. They do not extend `customImages`. A repository preview is derived only from the entered owner/name, URL, and description; it does not query GitHub. Link and image order is preserved. +The editor adds, removes, edits, and reorders contributions with explicit keyboard-accessible move controls. Images use the existing filtered derived-HTML policy and shared budget, with exact `customHtml` ranges; they do not extend `customImages`. Repository URLs use the ordinary link editor. Link and image order is preserved. ## Alternatives Considered - Replace `url` in v2: would discard the legacy shape or require a migration on recovery. Keep it as the fallback when the additive field is absent. -- Fetch repository metadata or upload images: would add network behavior, credentials or hosting requirements. Use manually entered public URLs and descriptions. +- Fetch repository metadata or upload images: would add network behavior, credentials or hosting requirements. Use manually entered public URLs and image descriptions. - Sort contributions automatically: would change user intent. Keep the saved order and expose move up/down controls. ## Consequences diff --git a/docs/decisions/0040-activity-card-columns.md b/docs/decisions/0040-activity-card-columns.md index cb4531e..90a79e8 100644 --- a/docs/decisions/0040-activity-card-columns.md +++ b/docs/decisions/0040-activity-card-columns.md @@ -9,7 +9,7 @@ Activity cards previously occupied a full row each. People want to choose a more ## Decision -Add the optional v2 field `metrics.cardColumns?: 1 | 2`. A new profile explicitly starts with two cards per row; an absent field in a recovered or migrated document keeps the previous one-card-per-row output. The editor exposes one or two cards per row. Generation preserves card order, pairs consecutive cards in GitHub Flavored Markdown tables, and leaves an unpaired last card by itself. The preview enables `remark-gfm` and renders tables in a horizontally scrollable, keyboard-focusable container. +Add the optional v2 field `metrics.cardColumns?: 1 | 2`. A new profile explicitly starts with two cards per row; an absent field in a recovered or migrated document keeps the previous one-card-per-row output. The editor exposes one or two cards per row. Generation preserves card order, pairs consecutive cards in GitHub Flavored Markdown tables, and leaves an unpaired last card by itself. In two-column mode, the shared SVG renderer increases text sizes to compensate for GitHub scaling each image to half the row width. The preview enables `remark-gfm` and renders tables in a horizontally scrollable, keyboard-focusable container. V1 remains strict. Recovery, migration, and project opening do not add the field. No card is selected or reordered as a side effect of changing the layout. @@ -21,6 +21,6 @@ V1 remains strict. Recovery, migration, and project opening do not add the field ## Consequences -New profiles can use the available README width more efficiently. Existing profile JSON remains compatible with its previous output until a person chooses a layout. GitHub controls final styling and may wrap the table on narrow screens; the local preview is an approximation of that presentation. +New profiles can use the available README width more efficiently. Existing profile JSON remains compatible with its previous output until a person chooses a layout. GitHub controls final styling and may wrap the table on narrow screens; the local preview is an approximation of that presentation. See [ADR 0041](0041-activity-contribution-order-and-readable-cards.md) for the independent order control for activity and manual contributions. See [Profile Model](../profile-model.md), [Editor](../editor.md), and [ADR 0031](0031-browser-persistent-activity-cache.md). diff --git a/docs/decisions/0041-activity-contribution-order-and-readable-cards.md b/docs/decisions/0041-activity-contribution-order-and-readable-cards.md new file mode 100644 index 0000000..b7e1bf3 --- /dev/null +++ b/docs/decisions/0041-activity-contribution-order-and-readable-cards.md @@ -0,0 +1,26 @@ +# ADR 0041: Activity and Contribution Order with Readable Cards + +- Status: Accepted. +- Date: 2026-10-23. + +## Context + +Activity cards and manually written contributions share one README section, but the generator always placed activity first. In two-column mode, GitHub scales each card SVG into half of the available row width, making its text difficult to read. + +## Decision + +Add the optional v2 field `metrics.contentOrder?: "activity-first" | "contributions-first"`. The editor offers an explicit choice next to the card layout controls. When both content groups are present, generation and preview place them in the selected order; absence preserves activity-first output. V1 remains strict, and recovery does not add a default. + +When `metrics.cardColumns` is `2`, all activity card families increase their SVG text sizes while retaining the same URLs, data, order, accessible descriptions, and one generated Markdown output. Both server-hosted cards and local preview use the same renderer behavior. One-column cards keep their current size. Card selection order and content group order remain independent choices. + +## Alternatives Considered + +- Add activity and contributions as separate fixed sections: would change the profile's section model and navigation. Keep them combined in the existing `contributions` slot. +- Adjust SVGs only in the editor preview: would make exported cards differ from the generated README. Apply the same validated `columns` option to local and hosted rendering. +- Increase card text for every layout: would unnecessarily change the existing one-column appearance. Apply the larger text only for two-column output. + +## Consequences + +People can choose which group appears first and can use two-column cards without reducing text to the previous tiny scale. Existing v2 documents retain the prior order and card appearance until they choose or already use the two-column layout. GitHub still controls table rendering and wrapping on narrow screens. + +See [Profile Model](../profile-model.md), [Editor](../editor.md), [ADR 0040](0040-activity-card-columns.md), and [ADR 0039](0039-contribution-details-and-order.md). diff --git a/docs/editor.md b/docs/editor.md index cca3c05..ad1de7c 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -146,7 +146,9 @@ Sections with invalid links, image URLs, alternative descriptions or HTML show a You can complete a manual contribution on its own. To add GitHub information, open the optional group, enter a username and request public activity. Typing a username, opening the group or restoring a draft does not trigger the form's query. Previously selected cards do reload their image when shown, just as they will on GitHub. -Each manual contribution has a title, description, and up to five links. You can add up to three public HTTPS images, each with alternative text, and an optional repository preview using a GitHub owner/repository URL and a description you write. The repository preview does not query GitHub. Use **Move up** and **Move down** to choose the order contributions appear in the README; adding links, images or a repository does not change that order. Older drafts keep their single URL unless you edit the links. +Each manual contribution has a title, description, and up to ten links. You can add up to three public HTTPS images, each with alternative text. Add a repository by entering its URL as an ordinary link. Use **Move up** and **Move down** to choose the order contributions appear in the README. Older drafts keep their single URL until you edit the contribution; retired repository preview data becomes a regular link and its description joins the contribution text when edited. + +In the activity section, choose whether activity cards or manual contributions appear first. Choose one or two cards per row; two-column cards use larger SVG text to stay readable at the narrower size. You can independently choose profile cards and query their public data: @@ -163,7 +165,7 @@ You can independently choose profile cards and query their public data: Appearance lets you choose a light/dark theme, three palettes, three custom layouts and compact/wide width, as well as 79 named Extended themes. Validated hex colors and gradients, title, border, icons, bold text and number formats let you customize cards without free-form CSS. Explicit width ranges from 700 to 1200, radius from 0 to 30, and line spacing from 22 to 50. Layout previews are local and decorative: they do not query GitHub or add data to the profile. Resetting removes only appearance settings and preserves usernames, destinations, cards, data options and contributions. Restoring a draft does not add default values. The card theme is independent of the editor and applies to every selected card. -Choose **One per row** or **Two per row** for README activity cards. Two-column rows keep the selected card order; an odd last card occupies its own row. New profiles start with two columns. Drafts that lack the setting retain the previous one-card-per-row output until you choose a layout. GitHub controls final table styling and may wrap rows on narrow screens. See [ADR 0040](decisions/0040-activity-card-columns.md). +Choose **One per row** or **Two per row** for README activity cards. Two-column rows keep the selected card order, enlarge card text, and leave an odd last card on its own row. New profiles start with two columns. Drafts that lack the setting retain the previous one-card-per-row output until you choose a layout. GitHub controls final table styling and may wrap rows on narrow screens. See [ADR 0040](decisions/0040-activity-card-columns.md) and [ADR 0041](decisions/0041-activity-contribution-order-and-readable-cards.md). Extended ranking starts disabled. When enabled, you can show a letter grade, the formula's percentage index or an icon; it uses commits, PRs, issues, reviews, stars and followers. It does not measure skill or an observed population percentile, and it is hidden if any input is missing or partial. Cards retain the MIT license and notices for adapted code and are generated on this server without calling Extended's hosted service. The [compatibility matrix](github-stats-extended.md) explains coverage, limits and differences from the source; see [ADR 0024](decisions/0024-extended-public-suite-and-mit.md) for the decision. @@ -177,7 +179,7 @@ Suggestions list public PRs and issues from the period. Choosing one adds an edi An invalid user, missing profile, unconfigured server, exhausted quota and service outage each have distinct messages. Zero activity is a valid result, not an error. If an update for the same user fails, the last valid result may remain visible with its date and a notice; no zeros or examples are invented. Changing the user discards the previous result, and late responses cannot replace it. -The card cache uses a versioned format separate from the localStorage draft and profile document. On restore it validates public data, identity, filters, periods and dates; it does not save errors, permissions, credentials, SVGs or Blob URLs. Older data can be shown for up to 24 hours with a notice, without changing its timestamp. Cleanup happens when reading or saving. Status indicates reviewing, saving, saved or RAM-only use if IndexedDB is unavailable. The profile is not saved in that database and is not synchronized across browsers or devices. The optional suggestion search has its own form state. The profile saves public usernames and destinations, cards, appearance, Extended options, and selected or manually written contributions. Contributions preserve their manual order and can have multiple links, images, and an entered repository preview; descriptions are not published in a dynamic card or in URLs. You can uncheck cards from an older draft even if the server cannot query them. GitHub samples and history have stated limits; WakaTime shows the last seven days without inventing dates if the provider does not supply them. +The card cache uses a versioned format separate from the localStorage draft and profile document. On restore it validates public data, identity, filters, periods and dates; it does not save errors, permissions, credentials, SVGs or Blob URLs. Older data can be shown for up to 24 hours with a notice, without changing its timestamp. Cleanup happens when reading or saving. Status indicates reviewing, saving, saved or RAM-only use if IndexedDB is unavailable. The profile is not saved in that database and is not synchronized across browsers or devices. The optional suggestion search has its own form state. The profile saves public usernames and destinations, cards, appearance, Extended options, and selected or manually written contributions. Contributions preserve their manual order and can have multiple links and images; older repository preview details are retained as a normal link and description. Descriptions are not published in a dynamic card or in URLs. You can uncheck cards from an older draft even if the server cannot query them. GitHub samples and history have stated limits; WakaTime shows the last seven days without inventing dates if the provider does not supply them. The application hosts cards as its own passive SVGs. Exporting them requires a public HTTPS origin configured by the server operator; GitHub cannot reach a local server. Without that origin, search and preview work locally. A notice appears beside copy and download, which remain disabled while cards are selected. Excluding cards preserves their configuration and lets you export manual contributions. People creating a profile are not asked to enter tokens or configure services. diff --git a/docs/product.md b/docs/product.md index 50a8bdf..5c2c006 100644 --- a/docs/product.md +++ b/docs/product.md @@ -72,7 +72,7 @@ The app includes Next.js App Router, TypeScript, a bilingual visual kit and `Pro Optional GitHub queries require server configuration with verifiable public credentials; manual profile creation remains available without it. The app hosts the SVG cards and updates them according to its cache; a graph snapshot is not saved in the draft. There is no AI assistance: a listed AI tool does not imply an integration. The local importer lets you paste or open `.md`, review sections and confirm with a backup. Project files let you download and open the complete profile as local JSON, with review and a separate backup, preserving hidden data and settings. There is no remote sync or custom CSS for README styling. -The single fixed activity section combines inherited contribution and metrics fields without offering metrics separately. Hiding it turns off both flags and preserves data; showing it again includes contributions and any cards that remain selected. Manual contributions can include several links, public images and a repository preview using a description written by the user; move controls choose their order. Activity cards can be shown one or two per row without changing their selection order. The document stores usernames and public destinations, selected cards, options and contributions. Queried public data is temporarily stored in IndexedDB, outside the profile, project files, Markdown and backups. Data is fresh for 30 minutes and may be shown as older data for up to 24 hours. **Search data** authorizes queries for the chosen account; afterward, available coverage is reused and only missing data for selected components is queried. Opening, typing or restoring a draft does not query GitHub. Reloading may restore cached data without authorizing new queries; if storage fails, editing continues in memory with a notice. See [ADR 0031](decisions/0031-browser-persistent-activity-cache.md), [ADR 0039](decisions/0039-contribution-details-and-order.md), and [ADR 0040](decisions/0040-activity-card-columns.md). +The single fixed activity section combines inherited contribution and metrics fields without offering metrics separately. Hiding it turns off both flags and preserves data; showing it again includes contributions and any cards that remain selected. Manual contributions can include up to ten ordinary links and three public images; move controls choose their order. Older repository preview data opens as a normal link and description. Activity cards can be shown one or two per row, with larger card text in the two-column layout. Choose whether activity or manual contributions appear first without changing card or contribution order. The document stores usernames and public destinations, selected cards, options and contributions. Queried public data is temporarily stored in IndexedDB, outside the profile, project files, Markdown and backups. Data is fresh for 30 minutes and may be shown as older data for up to 24 hours. **Search data** authorizes queries for the chosen account; afterward, available coverage is reused and only missing data for selected components is queried. Opening, typing or restoring a draft does not query GitHub. Reloading may restore cached data without authorizing new queries; if storage fails, editing continues in memory with a notice. See [ADR 0031](decisions/0031-browser-persistent-activity-cache.md), [ADR 0039](decisions/0039-contribution-details-and-order.md), [ADR 0040](decisions/0040-activity-card-columns.md), and [ADR 0041](decisions/0041-activity-contribution-order-and-readable-cards.md). Original code uses MIT with attribution to Juan Nicolas Herrera; contributors and third-party materials retain their respective rights holders. Commercial reuse and derivatives are permitted under the terms of their licenses. The [open-source guide](open-source.md) documents notices, the inventory and distribution. The generator's license does not change the rights to content someone includes in their README. diff --git a/docs/profile-model.md b/docs/profile-model.md index 9132606..77dc327 100644 --- a/docs/profile-model.md +++ b/docs/profile-model.md @@ -64,17 +64,17 @@ The JSON shape uses `""` for empty text, `[]` for empty collections, and `null` Every field is present in each entry; text may be empty. The table describes the persisted format, not editor options. The linked schemas are the exact reference. `knowledge`, `education`, and `current` are retired structures that are preserved internally. -| Module | Content shape | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `presentation` | `displayName`, `headline`, `bio`, `location`; only v2 supports optional `format`. | -| `knowledge` | `items`: `id`, `title`, `description`. | -| `technologies` | `groups`: `id`, `name`, `items`; each technology has `id`, `name`, and `context`. | -| `education` | `items`: `id`, `kind`, `title`, `institution`, `status`, `startDate`, `endDate`, `description`, `url`. | -| `current` | `items`: `id`, `kind`, `description`, `url`. | -| `projects` | `items`: `id`, `name`, `description`, `role`, `technologies` as a text array, and `links`; v2 adds `titleUrl?` and `cover?: { url, alt }`. | -| `contributions` | `items`: `id`, `title`, `description`, legacy `url`; v2 adds optional `links`, `images`, and `repository`. | -| `metrics` | `username`, `cards`, `appearance?`, `extended?`, and v2 `cardColumns?`; ten unique types and public options. It is composed within contributions. | -| `contact` | `links`. | +| Module | Content shape | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `presentation` | `displayName`, `headline`, `bio`, `location`; only v2 supports optional `format`. | +| `knowledge` | `items`: `id`, `title`, `description`. | +| `technologies` | `groups`: `id`, `name`, `items`; each technology has `id`, `name`, and `context`. | +| `education` | `items`: `id`, `kind`, `title`, `institution`, `status`, `startDate`, `endDate`, `description`, `url`. | +| `current` | `items`: `id`, `kind`, `description`, `url`. | +| `projects` | `items`: `id`, `name`, `description`, `role`, `technologies` as a text array, and `links`; v2 adds `titleUrl?` and `cover?: { url, alt }`. | +| `contributions` | `items`: `id`, `title`, `description`, legacy `url`; v2 adds optional `links`, `images`, and legacy `repository` data. | +| `metrics` | `username`, `cards`, `appearance?`, `extended?`, and v2 `cardColumns?`/`contentOrder?`; ten unique types and public options. It is composed within contributions. | +| `contact` | `links`. | Links have `id`, `label`, and `url`. Each `id` must contain at least one non-whitespace character and be unique within its collection; different collections may reuse an ID. Recovered IDs are not trimmed. Operations that need new identities receive an explicit function; the core does not generate them randomly. @@ -84,7 +84,7 @@ Projects generate level-three titles linked to `titleUrl` when it exists. Earlie V2 technologies support `layout?: "compact" | "grouped"`. When absent, the previous category-based output is preserved; recovery or migration from v1 does not add the field. Only a new v2 profile starts with `compact`. Compact presentation derives `kbd` labels with names, decorative icons, and inline categories using `customHtml`, its exact ranges, and the shared budget, without extending `customImages`. Notes preserve their paragraphs and order. All operations retain the selected option and content structure. See [ADR 0035](decisions/0035-compact-technologies.md). -V2 contribution items keep their existing `id`, `title`, `description`, and `url`, and add optional `links` (up to five `{ id, label, url }` entries), `images` (up to three `{ id, url, alt }` entries), and `repository: { url, description }`. When `links` is absent, generation uses the legacy `url`; when present, the links collection is authoritative. Images require public HTTPS URLs and non-empty alternative text. Repository previews accept only GitHub owner/repository URLs and use the description entered by the user; they do not fetch repository metadata. Their derived HTML shares the existing policy, document budget and exact `customHtml` ranges, without expanding `customImages`. The form preserves array order and offers explicit contribution move controls. V1 remains closed and migration does not add these fields. See [ADR 0039](decisions/0039-contribution-details-and-order.md). +V2 contribution items keep their existing `id`, `title`, `description`, and `url`, and add optional `links` (up to ten `{ id, label, url }` entries) and `images` (up to three `{ id, url, alt }` entries). When `links` is absent, generation uses the legacy `url`; when present, it is authoritative. Images require public HTTPS URLs and non-empty alternative text. `repository: { url, description }` remains accepted only so older v2 drafts can be opened; it renders as an ordinary link and description, and the first edit moves that data into the normal fields. The editor does not offer a repository preview or fetch metadata. Image HTML still uses the existing policy, budget, and exact `customHtml` ranges, without expanding `customImages`. The form preserves array order and offers explicit contribution move controls. V1 remains closed and recovery does not migrate fields. See [ADR 0039](decisions/0039-contribution-details-and-order.md). Technology autocomplete uses a pure catalog of names, aliases, and suggested categories without adding fields to the document. A new selection stores the name and `context: ""` in the selected or suggested group; a custom tool may use an untitled group. The UI creates new IDs outside core. Pure operations receive an ID function, preserve content, and resolve duplicate item IDs across groups. Removing a category only clears its name; unnamed empty groups are preserved internally. The catalog resolves icons during generation without storing URLs or catalog IDs. Recovery or a language change does not reorganize the document; organizing is an explicit operation that preserves tools, notes, and recovered duplicates. @@ -96,7 +96,7 @@ Location keeps `presentation.location` as a free-form string. An explicit select `data.metrics.cards` accepts `calendar`, `summary`, `languages`, `panorama`, `monthly`, `extended-stats`, `extended-languages`, `extended-repository`, `extended-gist`, and `extended-wakatime`, without duplicates. The three resource selections generate one card per configured destination and do not require `metrics.username`; the seven profile cards do require a valid username. -V2 also supports `cardColumns?: 1 | 2`. New profiles set it to `2`; when absent, generation keeps the previous one-card-per-row output. Two-column output preserves card selection order in GFM table rows and puts an odd final card on its own. Recovery and v1 migration do not add the option; v1 remains strict. See [ADR 0040](decisions/0040-activity-card-columns.md). +V2 also supports `cardColumns?: 1 | 2` and `contentOrder?: "activity-first" | "contributions-first"`. New profiles set `cardColumns` to `2`; when absent, generation keeps the previous one-card-per-row output. Two-column output preserves card selection order in GFM table rows, puts an odd final card on its own, and increases SVG text size for the reduced cell width. `contentOrder` chooses whether cards or manual contributions appear first in the combined section; absence preserves activity-first output. Recovery and v1 migration do not add either option; v1 remains strict. See [ADR 0040](decisions/0040-activity-card-columns.md) and [ADR 0041](decisions/0041-activity-contribution-order-and-readable-cards.md). `appearance?` is strict and preserves `theme?: "light" | "dark"`, `palette?: "forest" | "ocean" | "violet"`, `design?: "classic" | "panels" | "minimal"`, and `size?: "compact" | "wide"`. It adds `preset?` with 79 named themes; hexadecimal `#RRGGBB` values for `titleColor/textColor/iconColor/borderColor/backgroundColor?`; `backgroundGradient?` with an angle from 0–360 and 2–10 colors; `borderRadius?` from 0–30; integer `width?` from 700–1200; integer line height `lineHeight?` from 22–50; `customTitle?` up to 100 units; booleans `hideTitle/hideBorder/showIcons/textBold?`; `numberFormat?: "short" | "long"`; and integer precision `numberPrecision?` from 0–2. It does not accept CSS, hosts, URLs, or unrestricted options. diff --git a/src/app/cards/extended/[card]/route.ts b/src/app/cards/extended/[card]/route.ts index 1e04722..2114c5e 100644 --- a/src/app/cards/extended/[card]/route.ts +++ b/src/app/cards/extended/[card]/route.ts @@ -39,6 +39,7 @@ export async function GET(request: Request, context: { params: Promise<{ card: s return result.success ? activitySvgResponse( renderResourceCard(result.data, input.locale, { + cardColumns: input.cardColumns, appearance: input.appearance, stale: result.stale || (profile?.success === true && profile.stale), ...input.options, @@ -48,7 +49,10 @@ export async function GET(request: Request, context: { params: Promise<{ card: s result.stale || (profile?.success === true && profile.stale), ) : activitySvgResponse( - renderResourceUnavailable(input.kind, input.locale, { appearance: input.appearance }), + renderResourceUnavailable(input.kind, input.locale, { + appearance: input.appearance, + cardColumns: input.cardColumns, + }), false, false, ); diff --git a/src/app/cards/github/[username]/[card]/route.ts b/src/app/cards/github/[username]/[card]/route.ts index 072a60b..7da1a8c 100644 --- a/src/app/cards/github/[username]/[card]/route.ts +++ b/src/app/cards/github/[username]/[card]/route.ts @@ -22,6 +22,7 @@ export async function GET( return extended.success ? activitySvgResponse( renderExtendedProfileCard(extended.data, input.card, input.locale, { + cardColumns: input.cardColumns, appearance: input.appearance, extended: cardOptions, stale: extended.stale, @@ -30,7 +31,10 @@ export async function GET( extended.stale, ) : activitySvgResponse( - renderActivityUnavailable(input.card, input.locale, { appearance: input.appearance }), + renderActivityUnavailable(input.card, input.locale, { + appearance: input.appearance, + cardColumns: input.cardColumns, + }), false, false, ); @@ -39,6 +43,7 @@ export async function GET( return result.success ? activitySvgResponse( renderActivityCard(result.data, input.card, input.locale, { + cardColumns: input.cardColumns, stale: result.stale, appearance: input.appearance, }), @@ -46,7 +51,10 @@ export async function GET( result.stale, ) : activitySvgResponse( - renderActivityUnavailable(input.card, input.locale, { appearance: input.appearance }), + renderActivityUnavailable(input.card, input.locale, { + appearance: input.appearance, + cardColumns: input.cardColumns, + }), false, false, ); diff --git a/src/core/AGENTS.md b/src/core/AGENTS.md index c9b253f..d10a5fb 100644 --- a/src/core/AGENTS.md +++ b/src/core/AGENTS.md @@ -24,8 +24,8 @@ Este ámbito contiene `ProfileDocument` v2, sus validaciones, operaciones puras, - Tratar texto y enlaces del perfil como entradas no confiables. Escapar contenido según el contexto de salida y restringir protocolos; no aceptar HTML arbitrario como atajo. - El generador produce Markdown escapado para los campos ordinarios; únicamente `html.source` interpreta HTML escrito. El formato visual construye HTML de texto escapado bajo la misma política; `code.source` siempre es literal. Compartir la política HTTP/HTTPS entre módulos activos y admitir `mailto:` solo en contacto, nunca dentro de HTML. Una URL suministrada e inválida en una sección activa y visible bloquea la salida completa, con la ruta exacta del campo; una URL opcional vacía no exige completarla. No resolver rutas relativas ni anclas HTML contra el origen del editor. - Unir `contributions` y `metrics` en la referencia fija de contribuciones, con inclusión efectiva OR; conservar las nueve referencias heredadas dentro del orden mixto y omitir la salida independiente de métricas. Los aportes manuales solo salen con `visibility.contributions`; las tarjetas solo con `visibility.metrics` y `cards` no vacío. No exigir usuario para contenido manual ni para métricas sin tarjetas. No reescribir flags al generar. -- Aportes v2 conserva `url` como fallback cuando falta `links`; admite hasta cinco enlaces, tres imágenes HTTPS públicas con `alt` no vacío y una URL GitHub `owner/repository` con descripción manual. V1 queda cerrada y recuperación/migración no agregan propiedades. Mantener orden y errores por ruta completa. Imágenes y preview de repositorio usan `renderProfileHtml`, presupuesto compartido y rangos exactos de `customHtml`, sin agregar `customImages` ni hacer consultas de red. Ver [ADR 0039](../../docs/decisions/0039-contribution-details-and-order.md). -- `metrics.cardColumns?: 1 | 2` pertenece solo a v2. Solo `createEmptyProfile` elige dos columnas; ausencia en documentos recuperados o migrados conserva una columna, sin default al parsear. Renderizar en GFM y mantener el orden de `cards`; en cantidad impar, la última tarjeta ocupa una fila propia. V1 queda cerrada. Ver [ADR 0040](../../docs/decisions/0040-activity-card-columns.md). +- Aportes v2 conserva `url` como fallback cuando falta `links`; admite hasta diez enlaces y tres imágenes HTTPS públicas con `alt` no vacío. El campo heredado `repository` solo se acepta para recuperar borradores antiguos; se genera como enlace y descripción normales, y la primera edición lo incorpora a los campos comunes. V1 queda cerrada y la recuperación no migra propiedades. Mantener orden y errores por ruta completa. Solo imágenes usan `renderProfileHtml`, presupuesto compartido y rangos exactos de `customHtml`, sin agregar `customImages` ni hacer consultas de red. Ver [ADR 0039](../../docs/decisions/0039-contribution-details-and-order.md). +- `metrics.cardColumns?: 1 | 2` y `contentOrder?: "activity-first" | "contributions-first"` pertenecen solo a v2. Solo `createEmptyProfile` elige dos columnas; ausencia conserva una columna y actividad antes de contribuciones, sin defaults al recuperar. Renderizar filas en GFM, agrandar tipografía SVG solo en dos columnas y mantener el orden de `cards`; en cantidad impar, la última tarjeta ocupa una fila propia. `contentOrder` reordena actividad y aportes manuales dentro de la referencia combinada. V1 queda cerrada. Ver [ADR 0040](../../docs/decisions/0040-activity-card-columns.md) y [ADR 0041](../../docs/decisions/0041-activity-contribution-order-and-readable-cards.md). - `generateReadme(document, { activityCardBaseUrl? })` sigue siendo puro. Recibe un origen público HTTPS explícito para las tarjetas. Sin él produce el Markdown con rutas locales y `exportIssues` con `activity_not_configured`, aunque `success` sea verdadero; los consumidores deben impedir exportar ese resultado. Un usuario inválido de tarjetas visibles devuelve `invalid_username`. Nunca construir orígenes desde Host ni exportar localhost. - Mantener `ActivitySnapshot` separado del documento persistido. Validar respuestas desconocidas, no convertir fallos en ceros y no generar datos de ejemplo. Los renderers SVG reciben fecha y estado stale explícitos; escapan XML, limitan texto, aceptan colores hex y no emiten enlaces, recursos externos, scripts ni `foreignObject`. Lenguajes describe bytes de repositorios analizados, sin inferir conocimientos ni atribuirlos al período del calendario. - `metrics.cards` de v2 admite sus siete IDs anteriores y `extended-repository`, `extended-gist`, `extended-wakatime`; v1 conserva sus tres originales y rechaza apariencia/opciones Extended. `appearance?` combina presets previos, 79 temas nombrados y colores hexadecimales/degradados/dimensiones acotados, sin CSS ni URLs. `extended?` contiene opciones estrictas de estadísticas, lenguajes, hasta doce repositorios/doce Gists y usuario WakaTime independiente. No insertar defaults; restablecer apariencia no cambia usuarios, destinos, opciones, selección ni aportes. Compartir construcción canónica y validación exacta de rutas y query, sin parámetros duplicados/desconocidos. `invalid_activity_options` señala destinos ausentes o configuraciones no representables; solo las tarjetas de perfil requieren `metrics.username`. Ver [ADR 0024](../../docs/decisions/0024-extended-public-suite-and-mit.md). diff --git a/src/core/activity/activity-urls.test.ts b/src/core/activity/activity-urls.test.ts index be5e59b..a07af38 100644 --- a/src/core/activity/activity-urls.test.ts +++ b/src/core/activity/activity-urls.test.ts @@ -52,11 +52,26 @@ describe("GitHub activity identifiers and public image URLs", () => { username: "alex", card: "calendar", locale: "es", + cardColumns: 1, localUrl: path, }); expect( parseActivityCardUrl(`https://profiles.example.com${path}`, "https://profiles.example.com"), - ).toEqual({ username: "alex", card: "calendar", locale: "es", localUrl: path }); + ).toEqual({ + username: "alex", + card: "calendar", + locale: "es", + cardColumns: 1, + localUrl: path, + }); + const twoColumnPath = "/cards/github/alex/calendar.svg?locale=es&columns=2"; + expect(activityCardUrl("alex", "calendar", "es", undefined, undefined, undefined, 2)).toBe( + twoColumnPath, + ); + expect(parseActivityCardUrl(twoColumnPath)).toMatchObject({ + cardColumns: 2, + localUrl: twoColumnPath, + }); }); it("keeps Extended cards recognizable when counters change after ranking is enabled", () => { diff --git a/src/core/activity/card-appearance.ts b/src/core/activity/card-appearance.ts index a23b31a..00da3b4 100644 --- a/src/core/activity/card-appearance.ts +++ b/src/core/activity/card-appearance.ts @@ -1,4 +1,4 @@ -import type { CardAppearance } from "../profile/module-content"; +import type { CardAppearance, CardColumns } from "../profile/module-content"; import { extendedThemes } from "./extended-themes"; const accents = { @@ -77,6 +77,20 @@ export function resolveCardAppearance(appearance: CardAppearance = {}) { export type ResolvedCardAppearance = ReturnType; +export function cardTypographyScale(columns: CardColumns = 1): number { + return columns === 2 ? 1.4 : 1; +} + +/** Keep cards readable when GitHub scales two SVGs into one table row. */ +export function scaleCardTypography(svg: string, columns: CardColumns = 1): string { + const scale = cardTypographyScale(columns); + if (scale === 1) return svg; + return svg.replace(/font-size="([\d.]+)"/g, (_, value: string) => { + const size = Number(value) * scale; + return `font-size="${Number.isInteger(size) ? size : size.toFixed(2)}"`; + }); +} + /** Passive SVG background from validated colors, never CSS or an external URL. */ export function cardBackground(appearance: ResolvedCardAppearance, height: number): string { const { gradient, width, background, border, radius, hideBorder } = appearance; diff --git a/src/core/activity/card-options.ts b/src/core/activity/card-options.ts index 44a3e70..4d87c5b 100644 --- a/src/core/activity/card-options.ts +++ b/src/core/activity/card-options.ts @@ -1,4 +1,8 @@ -import { cardAppearanceSchema, type CardAppearance } from "../profile/module-content"; +import { + cardAppearanceSchema, + type CardAppearance, + type CardColumns, +} from "../profile/module-content"; import type { ContentLocale } from "../profile/profile-schema"; import { cardConfigJson } from "./card-config-json"; import { @@ -35,13 +39,20 @@ const numericKeys = new Set(["borderRadius", "width", "lineHeight", "numberPreci const booleanKeys = new Set(["hideTitle", "hideBorder", "showIcons", "textBold"]); export type ProfileCardOptions = { locale: ContentLocale; + cardColumns: CardColumns; appearance?: CardAppearance; extended?: { stats?: ExtendedStatsOptions; languages?: ExtendedLanguageOptions }; }; /** Bounded validated options; never CSS, executable markup or external resources. */ export function parseCardOptions(params: URLSearchParams): ProfileCardOptions | null { - const allowed = ["locale", ...cardAppearanceKeys, "stats", "languages"] as readonly string[]; + const allowed = [ + "locale", + "columns", + ...cardAppearanceKeys, + "stats", + "languages", + ] as readonly string[]; if ( params.toString().length > 24_000 || [...params.keys()].some((key) => !allowed.includes(key) || params.getAll(key).length !== 1) @@ -49,6 +60,8 @@ export function parseCardOptions(params: URLSearchParams): ProfileCardOptions | return null; const locale = params.get("locale") ?? "en"; if (locale !== "en" && locale !== "es") return null; + const columns = params.get("columns") ?? "1"; + if (columns !== "1" && columns !== "2") return null; const input: Record = {}; for (const key of cardAppearanceKeys) { if (!params.has(key)) continue; @@ -91,6 +104,7 @@ export function parseCardOptions(params: URLSearchParams): ProfileCardOptions | } return { locale, + cardColumns: Number(columns) as CardColumns, ...(Object.keys(input).length ? { appearance: parsed.data } : {}), ...(Object.keys(extended).length ? { extended } : {}), }; @@ -100,8 +114,10 @@ export function cardQuery( locale: "en" | "es", appearance?: CardAppearance, extended?: ProfileCardOptions["extended"], + cardColumns: CardColumns = 1, ): string { const params = new URLSearchParams({ locale }); + if (cardColumns === 2) params.set("columns", "2"); for (const key of cardAppearanceKeys) { const value = appearance?.[key as keyof CardAppearance]; if (value !== undefined) diff --git a/src/core/activity/index.ts b/src/core/activity/index.ts index b69da57..6b6b559 100644 --- a/src/core/activity/index.ts +++ b/src/core/activity/index.ts @@ -1,6 +1,7 @@ import { profileActivityCardIds, type CardAppearance, + type CardColumns, type MetricCardId, } from "../profile/module-content"; import type { ContentLocale } from "../profile/profile-schema"; @@ -44,6 +45,7 @@ export function activityCardUrl( baseUrl?: string, appearance?: CardAppearance, extended?: ProfileCardOptions["extended"], + cardColumns: CardColumns = 1, ): string | null { const normalized = normalizeGitHubUsername(username); if (!normalized) return null; @@ -56,7 +58,7 @@ export function activityCardUrl( : card === "extended-languages" ? { languages: extended?.languages } : undefined; - const result = `${origin}/cards/github/${normalized}/${card}.svg?${cardQuery(locale, appearance, cardOptions)}`; + const result = `${origin}/cards/github/${normalized}/${card}.svg?${cardQuery(locale, appearance, cardOptions, cardColumns)}`; return result.length <= 12_000 ? result : null; } @@ -79,6 +81,7 @@ export function parseActivityCardUrl(value: string, baseUrl?: string) { baseUrl, options.appearance, options.extended, + options.cardColumns, ) === value ) { return { @@ -92,6 +95,7 @@ export function parseActivityCardUrl(value: string, baseUrl?: string) { undefined, options.appearance, options.extended, + options.cardColumns, )!, }; } diff --git a/src/core/activity/render-card.ts b/src/core/activity/render-card.ts index b4dea1b..712732b 100644 --- a/src/core/activity/render-card.ts +++ b/src/core/activity/render-card.ts @@ -8,6 +8,7 @@ import type { ActivitySnapshot } from "./activity-types"; import { cardBackground, resolveCardAppearance, + scaleCardTypography, type ResolvedCardAppearance, } from "./card-appearance"; import { getMonthlyActivity } from "./render-monthly"; @@ -294,6 +295,7 @@ function renderLanguages( snapshot: ActivitySnapshot, locale: ContentLocale, stale: boolean, + cardColumns: 1 | 2, ): string { const t = labels[locale]; const allItems = snapshot.languages.items.filter((item) => item.bytes > 0); @@ -302,7 +304,9 @@ function renderLanguages( .slice(0, 10); const totalBytes = allItems.reduce((total, item) => total + item.bytes, 0); const scope = languageScope(snapshot, t, locale); - const rowsHeight = Math.max(items.length, 1) * 28; + const rowHeight = cardColumns === 2 ? 36 : 28; + const notesStep = cardColumns === 2 ? 22 : 18; + const rowsHeight = Math.max(items.length, 1) * rowHeight; const notesStart = 100 + rowsHeight; const height = notesStart + (snapshot.languages.truncated ? 108 : 88); const rows = @@ -310,7 +314,8 @@ function renderLanguages( ? text(24, 97, t.noLanguages, 'font-size="12" fill="#4d6157"') : items .map((item, index) => { - const y = 92 + index * 28; + const y = 92 + index * rowHeight; + const barY = cardColumns === 2 ? y + 5 : y - 10; const fraction = item.bytes / totalBytes; const percent = fraction * 100; const percentText = @@ -322,8 +327,15 @@ function renderLanguages( }).format(fraction); const color = item.color && /^#[a-f\d]{6}$/i.test(item.color) ? item.color : "#327c50"; return ( - text(24, y, Array.from(item.name).slice(0, 28).join(""), 'font-size="12"') + - `` + + text( + 24, + y, + Array.from(item.name) + .slice(0, cardColumns === 2 ? 20 : 28) + .join(""), + 'font-size="12"', + ) + + `` + text(676, y, percentText, 'font-size="12" text-anchor="end" fill="#4d6157"') ); }) @@ -342,9 +354,9 @@ function renderLanguages( heading("languages", snapshot, locale) + rows + text(24, notesStart, scope, 'font-size="11" fill="#4d6157"') + - text(24, notesStart + 18, t.languageScope, 'font-size="11" fill="#4d6157"') + + text(24, notesStart + notesStep, t.languageScope, 'font-size="11" fill="#4d6157"') + (snapshot.languages.truncated - ? text(24, notesStart + 36, t.truncated, 'font-size="11" fill="#80541c"') + ? text(24, notesStart + notesStep * 2, t.truncated, 'font-size="11" fill="#80541c"') : "") + footer(snapshot, locale, height, stale, false), ); @@ -549,6 +561,7 @@ function languageBlock( appearance: ResolvedCardAppearance, y: number, limit = 10, + cardColumns: 1 | 2 = 1, ): { body: string; height: number } { const t = labels[locale]; const { items, totalBytes } = sortedLanguages(snapshot, limit); @@ -558,6 +571,7 @@ function languageBlock( height: 42, }; const { width, design } = appearance; + const labelLimit = cardColumns === 2 ? 20 : 28; if (design === "minimal") { let offset = 24; const strip = items @@ -601,7 +615,12 @@ function languageBlock( if (design === "panels") { return ( panel(x, top, cellWidth, rowHeight - 8, appearance) + - text(x + 12, top + 21, Array.from(item.name).slice(0, 28).join(""), 'font-size="12"') + + text( + x + 12, + top + 21, + Array.from(item.name).slice(0, labelLimit).join(""), + 'font-size="12"', + ) + text( x + cellWidth - 12, top + 21, @@ -614,7 +633,7 @@ function languageBlock( const barX = x + Math.min(200, cellWidth * 0.33); const barWidth = cellWidth - (barX - x) - 88; return ( - text(x, top + 17, Array.from(item.name).slice(0, 28).join(""), 'font-size="12"') + + text(x, top + 17, Array.from(item.name).slice(0, labelLimit).join(""), 'font-size="12"') + `` + text( x + cellWidth, @@ -634,23 +653,25 @@ function languageNotes( appearance: ResolvedCardAppearance, y: number, includePeriod = false, + cardColumns: 1 | 2 = 1, ): { body: string; height: number } { const t = labels[locale]; + const rowHeight = cardColumns === 2 ? 22 : 18; const notes = [languageScope(snapshot, t, locale), t.languageScope]; if (includePeriod) notes.push(t.languagePeriod); let body = notes .map((note, index) => - text(24, y + index * 18, note, `font-size="11" fill="${appearance.muted}"`), + text(24, y + index * rowHeight, note, `font-size="11" fill="${appearance.muted}"`), ) .join(""); if (snapshot.languages.truncated) body += text( 24, - y + notes.length * 18, + y + notes.length * rowHeight, t.truncated, `font-size="11" fill="${appearance.warning}"`, ); - return { body, height: (notes.length + Number(snapshot.languages.truncated)) * 18 }; + return { body, height: (notes.length + Number(snapshot.languages.truncated)) * rowHeight }; } function languageDescription(snapshot: ActivitySnapshot, locale: ContentLocale): string { @@ -665,6 +686,7 @@ function renderStyledCard( locale: ContentLocale, stale: boolean, appearance: ResolvedCardAppearance, + cardColumns: 1 | 2, ): string { const t = labels[locale]; let body = styledHeading(card, snapshot, locale, appearance); @@ -726,10 +748,18 @@ function renderStyledCard( appearance, nextY, card === "panorama" ? 5 : 10, + cardColumns, ); body += languages.body; nextY += languages.height + 16; - const notes = languageNotes(snapshot, locale, appearance, nextY, card === "panorama"); + const notes = languageNotes( + snapshot, + locale, + appearance, + nextY, + card === "panorama", + cardColumns, + ); body += notes.body; nextY += notes.height + 8; contentDescription += languageDescription(snapshot, locale); @@ -911,27 +941,41 @@ export function renderActivityCard( snapshot: ActivitySnapshot, card: MetricCardId, locale: ContentLocale, - options: { stale?: boolean; appearance?: CardAppearance } = {}, + options: { stale?: boolean; appearance?: CardAppearance; cardColumns?: 1 | 2 } = {}, ): string { + const cardColumns = options.cardColumns ?? 1; if (card === "extended-stats" || card === "extended-languages") { - return renderExtendedCard(snapshot, card, locale, options); + return scaleCardTypography(renderExtendedCard(snapshot, card, locale, options), cardColumns); } if (options.appearance || card === "panorama" || card === "monthly") { - return renderStyledCard( - snapshot, - card, - locale, - options.stale === true, - resolveCardAppearance(options.appearance), + return scaleCardTypography( + renderStyledCard( + snapshot, + card, + locale, + options.stale === true, + resolveCardAppearance(options.appearance), + cardColumns, + ), + cardColumns, ); } switch (card) { case "calendar": - return renderCalendar(snapshot, locale, options.stale === true); + return scaleCardTypography( + renderCalendar(snapshot, locale, options.stale === true), + cardColumns, + ); case "summary": - return renderSummary(snapshot, locale, options.stale === true); + return scaleCardTypography( + renderSummary(snapshot, locale, options.stale === true), + cardColumns, + ); case "languages": - return renderLanguages(snapshot, locale, options.stale === true); + return scaleCardTypography( + renderLanguages(snapshot, locale, options.stale === true, cardColumns), + cardColumns, + ); } } @@ -939,7 +983,7 @@ export function renderActivityCard( export function renderActivityUnavailable( card: MetricCardId, locale: ContentLocale, - options: { appearance?: CardAppearance } = {}, + options: { appearance?: CardAppearance; cardColumns?: 1 | 2 } = {}, ): string { const t = labels[locale]; if ( @@ -950,24 +994,30 @@ export function renderActivityUnavailable( card === "extended-languages" ) { const appearance = resolveCardAppearance(options.appearance); - return styledFrame( + return scaleCardTypography( + styledFrame( + card, + locale, + 154, + `${t.unavailable} ${t.unavailableHelp}`, + text(24, 32, t[card], 'font-size="18" font-weight="650"') + + text(24, 86, t.unavailable, 'font-size="13" font-weight="600"') + + text(24, 113, t.unavailableHelp, `font-size="12" fill="${appearance.muted}"`), + appearance, + ), + options.cardColumns, + ); + } + return scaleCardTypography( + frame( card, locale, 154, `${t.unavailable} ${t.unavailableHelp}`, text(24, 32, t[card], 'font-size="18" font-weight="650"') + - text(24, 86, t.unavailable, 'font-size="13" font-weight="600"') + - text(24, 113, t.unavailableHelp, `font-size="12" fill="${appearance.muted}"`), - appearance, - ); - } - return frame( - card, - locale, - 154, - `${t.unavailable} ${t.unavailableHelp}`, - text(24, 32, t[card], 'font-size="18" font-weight="650"') + - text(24, 77, t.unavailable, 'font-size="13" font-weight="600"') + - text(24, 104, t.unavailableHelp, 'font-size="12" fill="#4d6157"'), + text(24, 77, t.unavailable, 'font-size="13" font-weight="600"') + + text(24, 104, t.unavailableHelp, 'font-size="12" fill="#4d6157"'), + ), + options.cardColumns, ); } diff --git a/src/core/activity/render-extended-profile.ts b/src/core/activity/render-extended-profile.ts index 1d70175..74c1e41 100644 --- a/src/core/activity/render-extended-profile.ts +++ b/src/core/activity/render-extended-profile.ts @@ -13,7 +13,9 @@ import type { ContentLocale } from "../profile/profile-schema"; import { calculateActivityRank, type ActivityRankValues } from "./activity-rank"; import { cardBackground, + cardTypographyScale, resolveCardAppearance, + scaleCardTypography, type ResolvedCardAppearance, } from "./card-appearance"; import { @@ -123,6 +125,7 @@ const labels = { } as const; type Labels = (typeof labels)[ContentLocale]; type RenderOptions = { + cardColumns?: 1 | 2; appearance?: CardAppearance; extended?: { stats?: ExtendedStatsOptions; languages?: ExtendedLanguageOptions }; stale?: boolean; @@ -216,8 +219,10 @@ function header( snapshot: ExtendedProfileSnapshot, title: string, style: ResolvedCardAppearance, + cardColumns: 1 | 2, ): { body: string; next: number } { - const maxTitle = Math.floor((style.width - 48) / 10); + const scale = cardTypographyScale(cardColumns); + const maxTitle = Math.floor((style.width - 48) / (10 * scale)); const body = (style.hideTitle ? "" @@ -232,7 +237,7 @@ function header( style.hideTitle ? 32 : 58, bounded( `${snapshot.displayName} · @${snapshot.username}`, - Math.floor((style.width - 48) / 7.6), + Math.floor((style.width - 48) / (7.6 * scale)), ), `font-size="13" fill="${style.muted}"`, ); @@ -243,17 +248,20 @@ function footer( lines: string[], startY: number, style: ResolvedCardAppearance, + cardColumns: 1 | 2, ): { body: string; height: number } { + const scale = cardTypographyScale(cardColumns); + const lineHeight = cardColumns === 2 ? 22 : 18; const wrapped = lines.flatMap((line) => - wrap(line, Math.max(30, Math.floor((style.width - 48) / 6.5))), + wrap(line, Math.max(30, Math.floor((style.width - 48) / (6.5 * scale)))), ); return { body: wrapped .map((line, index) => - text(24, startY + index * 18, line, `font-size="11" fill="${style.muted}"`), + text(24, startY + index * lineHeight, line, `font-size="11" fill="${style.muted}"`), ) .join(""), - height: startY + Math.max(0, wrapped.length - 1) * 18 + 22, + height: startY + Math.max(0, wrapped.length - 1) * lineHeight + 22, }; } @@ -311,9 +319,10 @@ function renderStats( ): string { const strings = labels[locale]; const statsOptions = options.extended?.stats ?? {}; + const textScale = cardTypographyScale(options.cardColumns); const items = statsOptions.items ?? defaultExtendedStats; const title = style.customTitle?.trim() || strings.stats; - const heading = header(snapshot, title, style); + const heading = header(snapshot, title, style, options.cardColumns ?? 1); const rankInput = statsOptions.showRank ? rankValues(snapshot) : null; const rank = rankInput ? calculateActivityRank(rankInput, statsOptions.commitsPeriod === "all") @@ -323,7 +332,7 @@ function renderStats( const valueX = rowRight; const scopeX = Math.max(270, rowRight - 120); const labelX = style.showIcons ? 48 : 24; - const maxLabel = Math.max(12, Math.floor((scopeX - labelX - 102) / 7.5)); + const maxLabel = Math.max(12, Math.floor((scopeX - labelX - 102) / (7.5 * textScale))); const rowHeight = Math.max(24, style.lineHeight); let body = heading.body; const rows = items @@ -333,7 +342,7 @@ function renderStats( const formattedValue = rowValue(id, metric, locale, style); const valueSize = Math.min( 14, - (rowRight - scopeX - 12) / Math.max(1, formattedValue.length * 0.65), + (rowRight - scopeX - 12) / Math.max(1, formattedValue.length * 0.65 * textScale), ); const background = style.design === "panels" @@ -419,7 +428,7 @@ function renderStats( : [strings.allPublic]), `${options.stale ? strings.stale : strings.updated}: ${formatDate(snapshot.fetchedAt, locale)} · ${strings.publicOnly}`, ]; - const ending = footer(notes, bodyBottom + 36, style); + const ending = footer(notes, bodyBottom + 36, style, options.cardColumns ?? 1); const description = [ title, `${snapshot.displayName} (@${snapshot.username})`, @@ -521,8 +530,9 @@ function renderLanguages( ): string { const strings = labels[locale]; const languageOptions = options.extended?.languages ?? {}; + const textScale = cardTypographyScale(options.cardColumns); const title = style.customTitle?.trim() || strings.languages; - const heading = header(snapshot, title, style); + const heading = header(snapshot, title, style, options.cardColumns ?? 1); const { segments, totalBytes, weighted } = languageSegments( snapshot, languageOptions, @@ -556,7 +566,10 @@ function renderLanguages( text( x + 16, y, - bounded(segment.name, Math.max(10, Math.floor((width - (value ? 140 : 24)) / 8))), + bounded( + segment.name, + Math.max(10, Math.floor((width - (value ? 140 : 24)) / (8 * textScale))), + ), `font-size="13" font-weight="${style.textBold ? 600 : 400}"`, ) + (value @@ -574,7 +587,7 @@ function renderLanguages( ); }; if (!segments.length) { - const note = footer([strings.noLanguages], heading.next, style); + const note = footer([strings.noLanguages], heading.next, style, options.cardColumns ?? 1); body += note.body; bottom = note.height; } else if (layout === "normal") { @@ -633,7 +646,7 @@ function renderLanguages( ...(snapshot.languages.truncated ? [strings.truncated] : []), `${options.stale ? strings.stale : strings.updated}: ${formatDate(snapshot.fetchedAt, locale)} · ${strings.publicOnly}`, ]; - const ending = footer(notes, bottom + 34, style); + const ending = footer(notes, bottom + 34, style, options.cardColumns ?? 1); const description = [ title, `${snapshot.displayName} (@${snapshot.username})`, @@ -652,7 +665,10 @@ export function renderExtendedProfileCard( options: RenderOptions = {}, ): string { const style = resolveCardAppearance(options.appearance); - return card === "extended-stats" - ? renderStats(snapshot, locale, options, style) - : renderLanguages(snapshot, locale, options, style); + return scaleCardTypography( + card === "extended-stats" + ? renderStats(snapshot, locale, options, style) + : renderLanguages(snapshot, locale, options, style), + options.cardColumns, + ); } diff --git a/src/core/activity/render-resource-card.ts b/src/core/activity/render-resource-card.ts index 73ef8d5..8d79472 100644 --- a/src/core/activity/render-resource-card.ts +++ b/src/core/activity/render-resource-card.ts @@ -2,7 +2,9 @@ import type { CardAppearance } from "../profile/module-content"; import type { ContentLocale } from "../profile/profile-schema"; import { cardBackground, + cardTypographyScale, resolveCardAppearance, + scaleCardTypography, type ResolvedCardAppearance, } from "./card-appearance"; import type { ResourceKind, ResourceSnapshot } from "./resource-types"; @@ -10,6 +12,7 @@ import type { ExtendedMetric } from "./extended-types"; import type { repositoryContributionStatIds } from "./extended-options"; export type ResourceCardRenderOptions = { + cardColumns?: 1 | 2; appearance?: CardAppearance; stale?: boolean; showOwner?: boolean; @@ -197,10 +200,16 @@ function heading( title: string, y: number, theme: ResolvedCardAppearance, + cardColumns: 1 | 2, ): string { if (theme.hideTitle) return ""; const x = theme.showIcons ? 48 : 24; - const line = boundedLines(title, Math.floor((theme.width - x - 24) / 11), 1)[0] ?? ""; + const line = + boundedLines( + title, + Math.floor((theme.width - x - 24) / (11 * cardTypographyScale(cardColumns))), + 1, + )[0] ?? ""; return ( (theme.showIcons ? icon(kind, 24, y - 15, theme) : "") + text(x, y, line, `font-size="19" font-weight="650" fill="${theme.title}"`) @@ -232,9 +241,13 @@ function statistic( theme: ResolvedCardAppearance, width: number, kind: "stars" | "forks" | "files" | "comments", + cardColumns: 1 | 2, ): string { const valueText = number(value, locale, theme); - const fontSize = Math.min(22, width / (Array.from(valueText).length * 0.63)); + const fontSize = Math.min( + 22, + width / (Array.from(valueText).length * 0.63 * cardTypographyScale(cardColumns)), + ); return ( (theme.showIcons ? icon(kind, x, y - 12, theme) : "") + text(x + (theme.showIcons ? 22 : 0), y, label, `font-size="12" fill="${theme.muted}"`) + @@ -254,6 +267,7 @@ function metadataCard( ): string { const t = labels[locale]; const theme = resolveCardAppearance(options.appearance); + const cardColumns = options.cardColumns ?? 1; const defaultTitle = snapshot.kind === "repository" ? options.showOwner @@ -262,7 +276,7 @@ function metadataCard( : `Gist · ${snapshot.target.slice(0, 12)}${snapshot.target.length > 12 ? "…" : ""}`; const title = theme.customTitle ?? defaultTitle; const owner = options.showOwner ? `${t.owner}: @${snapshot.owner}` : t[snapshot.kind]; - const maxUnits = Math.floor((theme.width - 56) / 7.5); + const maxUnits = Math.floor((theme.width - 56) / (7.5 * cardTypographyScale(cardColumns))); const description = boundedLines( snapshot.description || t.noDescription, maxUnits, @@ -298,7 +312,7 @@ function metadataCard( const thirdColumn = Math.round((theme.width * 2) / 3) + 8; const statisticWidth = secondColumn - 48; const body = - heading(snapshot.kind, title, 34, theme) + + heading(snapshot.kind, title, 34, theme, cardColumns) + text(24, theme.hideTitle ? 34 : 56, owner, `font-size="11" fill="${theme.muted}"`) + description .map((line, index) => @@ -313,7 +327,17 @@ function metadataCard( (theme.design === "panels" ? `` : "") + - statistic(24, statsY, t.stars, snapshot.stars, locale, theme, statisticWidth, "stars") + + statistic( + 24, + statsY, + t.stars, + snapshot.stars, + locale, + theme, + statisticWidth, + "stars", + cardColumns, + ) + statistic( secondColumn, statsY, @@ -323,6 +347,7 @@ function metadataCard( theme, statisticWidth, "forks", + cardColumns, ) + (snapshot.language ? `` + @@ -347,6 +372,7 @@ function metadataCard( theme, statisticWidth, "files", + cardColumns, ) + statistic( secondColumn, @@ -357,6 +383,7 @@ function metadataCard( theme, statisticWidth, "comments", + cardColumns, ) : "") + (contributionRows.length @@ -424,7 +451,7 @@ function wakaTimeCard( : t.lastSevenDays; const title = theme.customTitle ?? t.wakatime; let body = - heading("wakatime", title, 34, theme) + + heading("wakatime", title, 34, theme, options.cardColumns ?? 1) + text( 24, 56 - titleOffset, @@ -489,30 +516,36 @@ export function renderResourceCard( locale: ContentLocale, options: ResourceCardRenderOptions = {}, ): string { - return snapshot.kind === "wakatime" - ? wakaTimeCard(snapshot, locale, options) - : metadataCard(snapshot, locale, options); + return scaleCardTypography( + snapshot.kind === "wakatime" + ? wakaTimeCard(snapshot, locale, options) + : metadataCard(snapshot, locale, options), + options.cardColumns, + ); } export function renderResourceUnavailable( kind: ResourceKind, locale: ContentLocale, - options: Pick = {}, + options: Pick = {}, ): string { const t = labels[locale]; const theme = resolveCardAppearance(options.appearance); const title = theme.customTitle ?? t[kind]; const titleOffset = theme.hideTitle ? 22 : 0; const body = - heading(kind, title, 34, theme) + + heading(kind, title, 34, theme, options.cardColumns ?? 1) + text(24, 81 - titleOffset, t.unavailable, 'font-size="14"') + text(24, 108 - titleOffset, t.unavailableHelp, `font-size="12" fill="${theme.muted}"`); - return frame( - title, - `${title}. ${t.unavailable} ${t.unavailableHelp}`, - locale, - theme, - 144 - titleOffset, - body, + return scaleCardTypography( + frame( + title, + `${title}. ${t.unavailable} ${t.unavailableHelp}`, + locale, + theme, + 144 - titleOffset, + body, + ), + options.cardColumns, ); } diff --git a/src/core/activity/resource-urls.ts b/src/core/activity/resource-urls.ts index ab45ab3..216b3a1 100644 --- a/src/core/activity/resource-urls.ts +++ b/src/core/activity/resource-urls.ts @@ -1,4 +1,4 @@ -import type { CardAppearance } from "../profile/module-content"; +import type { CardAppearance, CardColumns } from "../profile/module-content"; import type { ContentLocale } from "../profile/profile-schema"; import { resourceCardOptionsSchema, type ResourceCardOptions } from "./extended-options"; import { normalizeActivityBaseUrl } from "./index"; @@ -13,11 +13,12 @@ export function resourceCardUrl( baseUrl?: string, appearance?: CardAppearance, options?: ResourceCardOptions, + cardColumns: CardColumns = 1, ): string | null { const normalized = normalizeResourceTarget(kind, target); const origin = baseUrl === undefined ? "" : normalizeActivityBaseUrl(baseUrl); if (!normalized || origin === null) return null; - const params = new URLSearchParams(cardQuery(locale, appearance)); + const params = new URLSearchParams(cardQuery(locale, appearance, undefined, cardColumns)); params.set("target", normalized); if (options && Object.keys(options).length) params.set("options", cardConfigJson(options)); const result = `${origin}/cards/extended/${kind}.svg?${params.toString()}`; @@ -58,6 +59,7 @@ export function parseResourceCardRequest(filename: string, url: URL) { target, locale: common.locale, appearance: common.appearance, + cardColumns: common.cardColumns, ...(options ? { options } : {}), }; } @@ -82,6 +84,7 @@ export function parseResourceCardUrl(value: string, baseUrl?: string) { baseUrl, input.appearance, input.options, + input.cardColumns, ) !== value ) return null; @@ -94,6 +97,7 @@ export function parseResourceCardUrl(value: string, baseUrl?: string) { undefined, input.appearance, input.options, + input.cardColumns, )!, }; } diff --git a/src/core/profile/contributions-schema.test.ts b/src/core/profile/contributions-schema.test.ts index 3877e6a..b82416b 100644 --- a/src/core/profile/contributions-schema.test.ts +++ b/src/core/profile/contributions-schema.test.ts @@ -7,7 +7,7 @@ function contribution() { } describe("contribution document schema", () => { - it("accepts the additive v2 links, images, and repository preview fields", () => { + it("accepts v2 links, images, and legacy repository fields", () => { const document = createEmptyProfile("en"); document.data.contributions.items = [ { @@ -37,7 +37,11 @@ describe("contribution document schema", () => { it.each([ { - links: Array.from({ length: 6 }, (_, index) => ({ id: `link-${index}`, label: "", url: "" })), + links: Array.from({ length: 11 }, (_, index) => ({ + id: `link-${index}`, + label: "", + url: "", + })), }, { images: Array.from({ length: 4 }, (_, index) => ({ diff --git a/src/core/profile/module-content.ts b/src/core/profile/module-content.ts index 803c764..f3a9be1 100644 --- a/src/core/profile/module-content.ts +++ b/src/core/profile/module-content.ts @@ -43,6 +43,9 @@ export const metricCardIds = Object.freeze([ "extended-wakatime", ] as const); export type MetricCardId = (typeof metricCardIds)[number]; +export type CardColumns = 1 | 2; +export type ActivityContentOrder = "activity-first" | "contributions-first"; +export const contributionLinksMax = 10; function uniqueCards>(schema: T) { return z.array(schema).superRefine((cards, context) => { @@ -230,12 +233,13 @@ export const moduleContentSchemas = Object.freeze({ contributions: z.strictObject({ items: entriesWithUniqueIds( moduleContentV1Schemas.contributions.shape.items.element.extend({ - links: entriesWithUniqueIds(linkSchema).max(5).optional(), + links: entriesWithUniqueIds(linkSchema).max(contributionLinksMax).optional(), images: entriesWithUniqueIds( z.strictObject({ id: entryIdSchema, url: z.string(), alt: z.string() }), ) .max(3) .optional(), + // Accepted only to recover v2 drafts created before repository previews were retired. repository: z.strictObject({ url: z.string(), description: z.string() }).optional(), }), ), @@ -247,6 +251,7 @@ export const moduleContentSchemas = Object.freeze({ username: z.string(), cards: uniqueCards(z.enum(metricCardIds)), cardColumns: z.union([z.literal(1), z.literal(2)]).optional(), + contentOrder: z.enum(["activity-first", "contributions-first"]).optional(), appearance: cardAppearanceSchema.optional(), extended: extendedOptionsSchema.optional(), }), diff --git a/src/core/readme/render-activity.ts b/src/core/readme/render-activity.ts index 70e2cb1..db5b744 100644 --- a/src/core/readme/render-activity.ts +++ b/src/core/readme/render-activity.ts @@ -88,6 +88,7 @@ export function renderActivity( origin ?? undefined, metrics.appearance, metrics.extended?.resourceOptions, + metrics.cardColumns ?? 1, ); if (!url) { errors.push({ @@ -117,6 +118,7 @@ export function renderActivity( : card === "extended-languages" ? { languages: metrics.extended?.languages } : undefined, + metrics.cardColumns ?? 1, ); if (!url) { errors.push({ code: "invalid_activity_options", path: ["data", "metrics", "extended"] }); @@ -127,12 +129,14 @@ export function renderActivity( } } - const blocks: { markdown: string; htmlFragments: readonly CustomHtmlFragment[] }[] = []; + const activityBlocks: { markdown: string; htmlFragments: readonly CustomHtmlFragment[] }[] = []; const imageMarkdown = layoutActivityCards(images, metrics.cardColumns ?? 1); - if (imageMarkdown) blocks.push({ markdown: imageMarkdown, htmlFragments: [] }); + if (imageMarkdown) activityBlocks.push({ markdown: imageMarkdown, htmlFragments: [] }); + const contributionBlocks: { markdown: string; htmlFragments: readonly CustomHtmlFragment[] }[] = + []; if (highlights.markdown) { const heading = `### ${labels.highlights}\n\n`; - blocks.push({ + contributionBlocks.push({ markdown: `${heading}${highlights.markdown}`, htmlFragments: highlights.htmlFragments.map((fragment) => ({ ...fragment, @@ -142,6 +146,11 @@ export function renderActivity( }); } + const blocks = + metrics.contentOrder === "contributions-first" + ? [...contributionBlocks, ...activityBlocks] + : [...activityBlocks, ...contributionBlocks]; + const content = joinBlocks(blocks.map((block) => block.markdown)); const sectionHeading = content ? `## ${labels.activity}\n\n` : ""; let bodyCursor = 0; diff --git a/src/core/readme/render-contributions.test.ts b/src/core/readme/render-contributions.test.ts index fd5042e..36e6a25 100644 --- a/src/core/readme/render-contributions.test.ts +++ b/src/core/readme/render-contributions.test.ts @@ -114,7 +114,7 @@ describe("manual contribution rendering", () => { expect(empty.htmlFragments).toEqual([]); }); - it("derives exact, escaped image and repository HTML ranges without customImages", () => { + it("keeps legacy repository details as normal link text beside safe image HTML", () => { const result = render( entry({ images: [ @@ -131,7 +131,7 @@ describe("manual contribution rendering", () => { }), ); expect(result.errors).toEqual([]); - expect(result.htmlFragments).toHaveLength(2); + expect(result.htmlFragments).toHaveLength(1); for (const fragment of result.htmlFragments) { expect(result.markdown.slice(fragment.start, fragment.end)).toBe(fragment.html); expect(fragment.tree.children[0]).toMatchObject({ type: "element", tagName: "div" }); @@ -140,9 +140,8 @@ describe("manual contribution rendering", () => { expect(result.htmlFragments[0]?.html).toContain( 'alt="Screenshot "', ); - expect(result.htmlFragments[1]?.html).toContain("Example/project"); - expect(result.htmlFragments[1]?.html).toContain("<script>safe text</script>"); - expect(result.markdown).toContain("https://github.com/Example/project"); + expect(result.markdown).toContain("[Example/project]()"); + expect(result.markdown).toContain("safe text"); }); it("shares the aggregate HTML budget and points an overflow to the contributing field", () => { diff --git a/src/core/readme/render-contributions.ts b/src/core/readme/render-contributions.ts index 3799cd5..481f68e 100644 --- a/src/core/readme/render-contributions.ts +++ b/src/core/readme/render-contributions.ts @@ -1,4 +1,4 @@ -import type { ElementContent, Root } from "hast"; +import type { Root } from "hast"; import { toHtml } from "hast-util-to-html"; import type { ProfileModuleData } from "../profile/module-content"; import { renderProfileHtml, type ProfileHtmlBudget } from "./derived-profile-html"; @@ -82,10 +82,31 @@ export function renderContributionItems( errors, ); }); + const repositoryUrl = item.repository?.url.trim(); + const repositoryTarget = repositoryUrl ? parseGitHubRepositoryUrl(repositoryUrl) : null; + if ( + repositoryUrl && + !item.links?.some((link) => link.url.trim() === repositoryUrl) && + !(!item.links && item.url.trim() === repositoryUrl) + ) { + const repositoryPath = ["data", "contributions", "items", index, "repository", "url"]; + if (!repositoryTarget) { + errors.push({ code: "invalid_url", path: repositoryPath }); + } else { + links.push(renderLink(repositoryTarget.fullName, repositoryUrl, repositoryPath, errors)); + } + } + const description = [item.description, item.repository?.description ?? ""] + .filter((value) => value.trim()) + .filter( + (value, descriptionIndex, values) => + values.findIndex((candidate) => candidate.trim() === value.trim()) === descriptionIndex, + ) + .join("\n\n"); append( renderList([ joinBlocks([ - renderTitledDescription(item.title, item.description), + renderTitledDescription(item.title, description), links.filter(Boolean).join(" · "), ]), ]), @@ -121,51 +142,6 @@ export function renderContributionItems( const fragment = renderProfileHtml(toHtml(tree), [...path, "url"], errors, htmlBudget); if (fragment) append(fragment.html, fragment); } - - const repository = item.repository; - if (repository && (repository.url.trim() || repository.description.trim())) { - const path = ["data", "contributions", "items", index, "repository"] as const; - const target = parseGitHubRepositoryUrl(repository.url); - if (!target) { - errors.push({ code: "invalid_url", path: [...path, "url"] }); - continue; - } - - const link: ElementContent = { - type: "element", - tagName: "a", - properties: { href: target.url }, - children: [ - { - type: "element", - tagName: "strong", - properties: {}, - children: [{ type: "text", value: target.fullName }], - }, - ], - }; - const children: ElementContent[] = [link]; - const description = normalizeInlineText(repository.description); - if (description) { - children.push( - { type: "element", tagName: "br", properties: {}, children: [] }, - { type: "text", value: description }, - ); - } - const tree: Root = { - type: "root", - children: [ - { - type: "element", - tagName: "p", - properties: {}, - children, - }, - ], - }; - const fragment = renderProfileHtml(toHtml(tree), [...path, "url"], errors, htmlBudget); - if (fragment) append(fragment.html, fragment); - } } return { diff --git a/src/features/editor/activity-data-cache.ts b/src/features/editor/activity-data-cache.ts index 9c76403..cac1d7b 100644 --- a/src/features/editor/activity-data-cache.ts +++ b/src/features/editor/activity-data-cache.ts @@ -342,6 +342,7 @@ export function createActivityDataCache(options: Options = {}) { ? { partial: true } : {}), svg: renderActivityCard(entry.data, card.input.card, card.input.locale, { + cardColumns: card.input.cardColumns, appearance: card.input.appearance, stale, }), @@ -367,6 +368,7 @@ export function createActivityDataCache(options: Options = {}) { ? { partial: true } : {}), svg: renderExtendedProfileCard(data, card.input.card, card.input.locale, { + cardColumns: card.input.cardColumns, appearance: card.input.appearance, extended: card.plan.options, stale, @@ -419,6 +421,7 @@ export function createActivityDataCache(options: Options = {}) { ...(supplement ? { supplement } : {}), svg: renderResourceCard(resource.data, card.input.locale, { ...card.input.options, + cardColumns: card.input.cardColumns, appearance: card.input.appearance, stale, ...(contributions ? { contributions } : {}), diff --git a/src/messages/en/modules/contributions.json b/src/messages/en/modules/contributions.json index 4722b05..2613c81 100644 --- a/src/messages/en/modules/contributions.json +++ b/src/messages/en/modules/contributions.json @@ -32,10 +32,16 @@ "choose": "What would you like to include?", "cardColumns": { "label": "Cards per row", - "help": "Choose one or two selected cards per row. GitHub may wrap them on narrow screens.", + "help": "Two cards fill the README width together, with larger text for readability.", "one": "One per row", "two": "Two per row" }, + "contentOrder": { + "label": "Section order", + "help": "Choose whether activity cards or your contributions appear first.", + "activityFirst": "Activity first", + "contributionsFirst": "Contributions first" + }, "groups": { "own": "Our activity cards", "extended": "GitHub Stats Extended layouts" @@ -317,7 +323,7 @@ "urlPlaceholder": "https://example.com/community-guide", "invalidUrl": "Enter a valid http:// or https:// address without spaces or sign-in credentials, or leave this field empty.", "linksTitle": "Links", - "linksHelp": "Add up to five destinations. A blank label is shown as “More information” in your README.", + "linksHelp": "Add up to ten destinations. A blank label is shown as “More information” in your README.", "linkLabel": "Link label", "linkLabelPlaceholder": "Documentation", "linkUrl": "Link URL", @@ -325,7 +331,7 @@ "addLink": "Add link", "removeLink": "Remove link {number}", "removeLinkVisible": "Remove link", - "linkLimit": "Each contribution can have up to five links.", + "linkLimit": "Each contribution can have up to ten links.", "imagesTitle": "Images", "imagesHelp": "Add up to three publicly accessible images. Each image needs descriptive alt text before export.", "imageUrl": "Image URL", @@ -339,16 +345,6 @@ "removeImage": "Remove image {number}", "removeImageVisible": "Remove image", "imageLimit": "Each contribution can have up to three images.", - "repositoryTitle": "GitHub repository preview", - "repositoryHelp": "Enter a public HTTPS github.com/owner/repository URL and an optional description. Repository details are not fetched.", - "repositoryUrl": "Repository URL", - "repositoryUrlPlaceholder": "https://github.com/owner/repository", - "repositoryDescription": "Repository description", - "repositoryDescriptionPlaceholder": "A short description of the repository", - "repositoryInvalid": "Enter an HTTPS github.com URL with exactly an owner/repository path.", - "repositoryPreview": "Preview: {repository}", - "addRepository": "Add repository preview", - "removeRepository": "Remove repository preview", "moveUp": "Move {title} up", "moveDown": "Move {title} down", "moveUpVisible": "Move up", @@ -357,5 +353,5 @@ "add": "Add contribution", "remove": "Remove contribution {number}", "removeVisible": "Remove contribution", - "removeHelp": "Removing this entry deletes its title, description, links, images, and repository preview from your draft. Hide the section instead to keep them." + "removeHelp": "Removing this entry deletes its title, description, links, and images from your draft. Hide the section instead to keep them." } diff --git a/src/messages/es/modules/contributions.json b/src/messages/es/modules/contributions.json index 4eb08ca..ff9b172 100644 --- a/src/messages/es/modules/contributions.json +++ b/src/messages/es/modules/contributions.json @@ -32,10 +32,16 @@ "choose": "¿Qué te gustaría incluir?", "cardColumns": { "label": "Tarjetas por fila", - "help": "Elegí una o dos tarjetas seleccionadas por fila. GitHub puede acomodarlas en pantallas angostas.", + "help": "Dos tarjetas ocupan el ancho del README y agrandan el texto para facilitar la lectura.", "one": "Una por fila", "two": "Dos por fila" }, + "contentOrder": { + "label": "Orden de las secciones", + "help": "Elegí si aparecen primero las tarjetas de actividad o tus contribuciones.", + "activityFirst": "Actividad primero", + "contributionsFirst": "Contribuciones primero" + }, "groups": { "own": "Tarjetas propias", "extended": "Diseños de GitHub Stats Extended" @@ -325,7 +331,7 @@ "urlPlaceholder": "https://example.com/guia-comunitaria", "invalidUrl": "Ingresá una dirección válida http:// o https:// sin espacios ni credenciales de acceso, o dejá este campo vacío.", "linksTitle": "Enlaces", - "linksHelp": "Agregá hasta cinco destinos. Si dejás la etiqueta vacía, en el README se muestra «Más información».", + "linksHelp": "Agregá hasta diez destinos. Si dejás la etiqueta vacía, en el README se muestra «Más información».", "linkLabel": "Etiqueta del enlace", "linkLabelPlaceholder": "Documentación", "linkUrl": "URL del enlace", @@ -333,7 +339,7 @@ "addLink": "Agregar enlace", "removeLink": "Quitar enlace {number}", "removeLinkVisible": "Quitar enlace", - "linkLimit": "Cada contribución puede tener hasta cinco enlaces.", + "linkLimit": "Cada contribución puede tener hasta diez enlaces.", "imagesTitle": "Imágenes", "imagesHelp": "Agregá hasta tres imágenes disponibles públicamente. Cada una necesita un texto alternativo descriptivo para exportar.", "imageUrl": "URL de la imagen", @@ -347,16 +353,6 @@ "removeImage": "Quitar imagen {number}", "removeImageVisible": "Quitar imagen", "imageLimit": "Cada contribución puede tener hasta tres imágenes.", - "repositoryTitle": "Vista previa del repositorio de GitHub", - "repositoryHelp": "Ingresá una URL HTTPS pública github.com/propietario/repositorio y una descripción opcional. No se consultan datos del repositorio.", - "repositoryUrl": "URL del repositorio", - "repositoryUrlPlaceholder": "https://github.com/propietario/repositorio", - "repositoryDescription": "Descripción del repositorio", - "repositoryDescriptionPlaceholder": "Una descripción breve del repositorio", - "repositoryInvalid": "Ingresá una URL HTTPS de github.com con la ruta propietario/repositorio exacta.", - "repositoryPreview": "Vista previa: {repository}", - "addRepository": "Agregar vista previa del repositorio", - "removeRepository": "Quitar vista previa del repositorio", "moveUp": "Subir {title}", "moveDown": "Bajar {title}", "moveUpVisible": "Subir", @@ -365,5 +361,5 @@ "add": "Agregar contribución", "remove": "Quitar contribución {number}", "removeVisible": "Quitar contribución", - "removeHelp": "Quitar esta entrada elimina su título, descripción, enlaces, imágenes y vista previa del repositorio. Para conservarlos, ocultá la sección." + "removeHelp": "Quitar esta entrada elimina su título, descripción, enlaces e imágenes. Para conservarlos, ocultá la sección." } diff --git a/src/modules/AGENTS.md b/src/modules/AGENTS.md index fa36563..d06f45b 100644 --- a/src/modules/AGENTS.md +++ b/src/modules/AGENTS.md @@ -9,7 +9,7 @@ Este ámbito contiene los cinco formularios fijos de presentación, tecnologías - Cada módulo controla su formulario y su presentación específica. Reutilizar el modelo y las reglas de `src/core/`; no definir un segundo formato del perfil dentro de un componente. - Proyectos usa una única URL opcional para enlazar el título. En documentos anteriores, derivar el destino del primer enlace útil, conservar la colección heredada en JSON y no incluir enlaces secundarios en el README. La descripción reúne qué hace el proyecto, el aporte o rol y las tecnologías; no volver a mostrar formularios independientes para `role` o `technologies`, pero conservar sus valores heredados al importar/exportar. La portada opcional v2 recibe URL HTTPS pública y alternativa; no carga archivos ni obtiene metadatos. Conservar los errores por campo y toda la fuente en JSON. Reutilizar el recorrido de HTML derivado y `PublicProfileImage` existente, sin ampliar `customImages`. La salida mantiene el orden título, descripción y portada. Ver [ADR 0033](../../docs/decisions/0033-project-showcase.md) y [ADR 0037](../../docs/decisions/0037-project-description-and-links.md). - Tecnologías expone su diseño del README cerca del inicio: compacto o títulos por categoría. Conservar `layout` opcional en todas las ediciones sin reescribirlo al abrir un borrador; la ausencia muestra títulos. No eliminar categorías ni notas para compactar la salida. Ver [ADR 0035](../../docs/decisions/0035-compact-technologies.md). -- Aportes manuales permiten hasta cinco enlaces, tres imágenes HTTPS públicas con texto alternativo y un preview manual de repositorio GitHub. Mantener URL heredada si no se editan los enlaces, dar errores por campo, conservar el orden y permitir mover aportes con botones accesibles; nunca obtener metadatos del repositorio. La vista previa y salida usan el mismo Markdown y HTML derivado validado. Ver [ADR 0039](../../docs/decisions/0039-contribution-details-and-order.md). +- Aportes manuales permiten hasta diez enlaces y tres imágenes HTTPS públicas con texto alternativo. Mantener URLs y previews heredados como enlaces comunes al editar, combinar la descripción previa de forma no destructiva, dar errores por campo, conservar el orden y permitir mover aportes con botones accesibles; nunca obtener metadatos del repositorio. La vista previa y salida usan el mismo Markdown y HTML derivado validado. Ver [ADR 0039](../../docs/decisions/0039-contribution-details-and-order.md). - Distinguir selección, inclusión y eliminación. La selección cambia el formulario en el panel de configuración; la casilla controla la participación en el README y preserva contenido al ocultar. Ocultar la sección activa conserva su formulario editable con un aviso de exclusión. Eliminar requiere una interacción que explique su efecto. - Todos los módulos son opcionales. Un bloque no puede exigir que el usuario complete otro sin una dependencia de datos real y documentada. - Diseñar un perfil personal breve, con presentación libre y detalles secundarios. Evitar pedir dos veces intereses o actividad actual. Conservar internamente los datos retirados sin editarlos, fusionarlos, inferirlos ni borrarlos. @@ -19,7 +19,7 @@ Este ámbito contiene los cinco formularios fijos de presentación, tecnologías - HTML se edita en CodeEditor etiquetado, con resaltado diferido y alternativa de texto simple y conserva `source` exactamente como se escribió. Usar `analyzeProfileHtml` para avisos de filtrado y problemas, sin una segunda política de validación. Asociar `invalid_html` a la ruta completa terminada en `source`, permitir corregirlo y mantener el código al ocultar, duplicar o recuperar. Mostrar guía plegable, límites y HTML exportado inspeccionable, con textos EN/ES. Los tres ejemplos de encabezado centrado, tabla y desplegable se insertan solo por elección explícita y confirman el reemplazo de fuente no vacía; las muestras no cargan recursos. No interpretar Markdown dentro del bloque ni ampliar otros campos. Explicar que enlaces son HTTP/HTTPS absolutos, imágenes HTTPS públicas con atributo `alt` obligatorio (`alt=""` solo decorativo), y que se filtran CSS, JavaScript y atributos no admitidos. No presentar la vista previa como réplica exacta de GitHub. Ver [ADR 0015](../../docs/decisions/0015-explicit-profile-html.md). - Duplicar, incluir/ocultar y eliminar instancias se coordinan mediante las operaciones puras del perfil en `features/editor`. No compartir objetos o IDs con la copia, borrar al ocultar ni interpretar títulos como identificadores. Los títulos repetidos o vacíos son válidos; la UI los distingue sin modificar el texto exportado. - Actividad y contribuciones conserva aportes manuales sin usuario de GitHub. Ofrecer diez tipos desactivados inicialmente con muestras locales. «Buscar datos» va debajo del usuario y funciona sin tarjetas: carga información común y activa la cuenta. Después, seleccionar componentes o personalizar consulta automáticamente solo cobertura faltante, agrupando cambios rápidos durante 300 ms; las opciones cubiertas dibujan las tarjetas localmente sin HTTP SVG o JSON. Recursos válidos aplicados y seleccionados también pueden cargarse tras Buscar. Abrir, recuperar o escribir un usuario no buscado no consulta. Mostrar progreso y errores incluso sin tarjetas; no reintentar fallos en bucle y respetar movimiento reducido. La búsqueda opcional de aportes usa otra acción. Repositorios y Gists admiten doce destinos de cada tipo; WakaTime usa usuario independiente. No pedir tokens ni configuración de despliegue. Ver [ADR 0027](../../docs/decisions/0027-browser-activity-data-cache.md) y [ADR 0029](../../docs/decisions/0029-account-first-activity-search.md). -- Los hooks validan snapshots, comprueban identidad y opciones, cancelan consultas y descartan respuestas tardías. La caché pública de tarjetas se conserva temporalmente en IndexedDB independiente, con identidad/alcance/fecha original y fallback a RAM. Restaurarla no consulta ni autoriza red; datos faltantes o vencidos esperan Buscar. Mostrar guardando/guardado/fallback con textos EN/ES. Desmontar conserva el store; reemplazar o borrar perfil lo purga. No guardar secretos, errores, permisos, SVG ni snapshots en `ProfileDocument`. En el documento guardar únicamente `metrics.username/cards/appearance?/extended?/cardColumns?` y las sugerencias elegidas como aportes manuales con IDs nuevos. `cardColumns` elige una o dos tarjetas por fila; ausencia conserva el diseño anterior, y solo el perfil nuevo inicia con dos. Mantener el orden seleccionado y previsualizar tablas GFM de forma accesible. Distinguir cero, parcialidad y dato ausente. No agregar destacados automáticamente ni deduplicarlos por títulos. Ver [ADR 0031](../../docs/decisions/0031-browser-persistent-activity-cache.md) y [ADR 0040](../../docs/decisions/0040-activity-card-columns.md). +- Los hooks validan snapshots, comprueban identidad y opciones, cancelan consultas y descartan respuestas tardías. La caché pública de tarjetas se conserva temporalmente en IndexedDB independiente, con identidad/alcance/fecha original y fallback a RAM. Restaurarla no consulta ni autoriza red; datos faltantes o vencidos esperan Buscar. Mostrar guardando/guardado/fallback con textos EN/ES. Desmontar conserva el store; reemplazar o borrar perfil lo purga. No guardar secretos, errores, permisos, SVG ni snapshots en `ProfileDocument`. En el documento guardar únicamente `metrics.username/cards/appearance?/extended?/cardColumns?/contentOrder?` y las sugerencias elegidas como aportes manuales con IDs nuevos. `cardColumns` elige una o dos tarjetas por fila; ausencia conserva el diseño anterior, y solo el perfil nuevo inicia con dos. El modo de dos columnas agranda texto en los mismos renderizadores locales y públicos. `contentOrder` decide entre actividad y aportes; ausencia conserva actividad primero. Mantener el orden seleccionado y previsualizar tablas GFM de forma accesible. Distinguir cero, parcialidad y dato ausente. No agregar destacados automáticamente ni deduplicarlos por títulos. Ver [ADR 0031](../../docs/decisions/0031-browser-persistent-activity-cache.md), [ADR 0040](../../docs/decisions/0040-activity-card-columns.md) y [ADR 0041](../../docs/decisions/0041-activity-contribution-order-and-readable-cards.md). - Ofrecer apariencia y opciones Extended mediante contratos de `core`, muestras locales decorativas y controles EN/ES, sin CSS libre ni datos de ejemplo persistidos. Restablecer quita solo apariencia. Conservar usuarios, destinos, opciones, tarjetas, aportes y ausencia de defaults al recuperar. Explicar muestra/histórico acotados y diferencias de la [suite compatible](../../docs/github-stats-extended.md). El ranking autorizado comienza desactivado; solo una elección explícita habilita su fórmula de actividad y debe aclarar que no mide habilidad ni percentil de población observado. Ocultarlo si faltan entradas completas, sin niveles de conocimiento. Ver [ADR 0024](../../docs/decisions/0024-extended-public-suite-and-mit.md). - Distinguir no configurado, usuario no encontrado, límite de uso, indisponibilidad y cero actividad. Permitir conservar o desmarcar tarjetas de borradores anteriores aunque la consulta falle. La falta de credenciales u origen público no bloquea los aportes manuales. - Formularios con etiquetas, instrucciones, errores asociados y operación por teclado. Ofrecer alternativas accesibles a arrastrar elementos. diff --git a/src/modules/contributions/contribution-items-editor.tsx b/src/modules/contributions/contribution-items-editor.tsx index b074db8..cff3148 100644 --- a/src/modules/contributions/contribution-items-editor.tsx +++ b/src/modules/contributions/contribution-items-editor.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Field } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; +import { contributionLinksMax } from "@/core/profile/module-content"; import { reorderContributionItem } from "@/core/profile/contribution-operations"; import type { ProfileModuleData } from "@/core/profile/module-content"; import type { ReadmeIssue } from "@/core/readme"; @@ -17,7 +18,10 @@ type ContributionLink = NonNullable[number]; type ContributionImage = NonNullable[number]; type SuggestedHighlight = ActivitySnapshot["highlights"][number]; -type EditorLink = { readonly link: ContributionLink; readonly legacy: boolean }; +type EditorLink = { + readonly link: ContributionLink; + readonly legacy: false | "url" | "repository"; +}; type ContributionItemsEditorProps = { value: ProfileModuleData["contributions"]; @@ -27,10 +31,51 @@ type ContributionItemsEditorProps = { }; function editorLinks(item: ContributionItem): EditorLink[] { - if (item.links !== undefined) return item.links.map((link) => ({ link, legacy: false })); - return item.url.trim() - ? [{ link: { id: "legacy-link", label: "", url: item.url }, legacy: true }] - : []; + const links: EditorLink[] = + item.links !== undefined + ? item.links.map((link) => ({ link, legacy: false })) + : item.url.trim() + ? [{ link: { id: "legacy-url", label: "", url: item.url }, legacy: "url" }] + : []; + const repository = item.repository; + const repositoryUrl = repository?.url.trim() ?? ""; + if (repository && repositoryUrl && !links.some(({ link }) => link.url.trim() === repositoryUrl)) { + const usedIds = new Set(links.map(({ link }) => link.id)); + let id = "legacy-repository"; + let suffix = 2; + while (usedIds.has(id)) id = `legacy-repository-${suffix++}`; + links.push({ + link: { + id, + label: parseGitHubRepositoryUrl(repositoryUrl)?.fullName ?? "", + url: repository.url, + }, + legacy: "repository", + }); + } + return links; +} + +function mergeDescriptions(...values: string[]) { + return values + .filter((value) => value.trim()) + .filter( + (value, index, all) => + all.findIndex((candidate) => candidate.trim() === value.trim()) === index, + ) + .join("\n\n"); +} + +function migrateLegacyContribution(item: ContributionItem): ContributionItem { + const links = editorLinks(item).map(({ link }) => link); + const next: ContributionItem = { + ...item, + description: mergeDescriptions(item.description, item.repository?.description ?? ""), + url: "", + links, + }; + delete next.repository; + return next; } function hasIssue( @@ -50,7 +95,8 @@ function suggestionIsAdded(items: readonly ContributionItem[], url: string) { return items.some( (item) => (item.links === undefined && item.url.trim() === url) || - item.links?.some((link) => link.url.trim() === url), + item.links?.some((link) => link.url.trim() === url) || + item.repository?.url.trim() === url, ); } @@ -107,7 +153,11 @@ export function ContributionItemsEditor({ } function updateEntry(id: string, update: (item: ContributionItem) => ContributionItem) { - onChange({ items: value.items.map((item) => (item.id === id ? update(item) : item)) }); + onChange({ + items: value.items.map((item) => + item.id === id ? update(migrateLegacyContribution(item)) : item, + ), + }); } function addEntry() { @@ -155,7 +205,7 @@ export function ContributionItemsEditor({ function addLink(item: ContributionItem) { const current = editorLinks(item).map(({ link }) => link); - if (current.length >= 5) return; + if (current.length >= contributionLinksMax) return; const id = crypto.randomUUID(); pendingNestedFocus.current = { kind: "links", key: nestedEntryKey(item.id, id) }; updateEntry(item.id, (currentItem) => ({ @@ -233,21 +283,6 @@ export function ContributionItemsEditor({ })); } - function updateRepository(item: ContributionItem, field: "url" | "description", text: string) { - updateEntry(item.id, (currentItem) => ({ - ...currentItem, - repository: { ...(currentItem.repository ?? { url: "", description: "" }), [field]: text }, - })); - } - - function removeRepository(item: ContributionItem) { - updateEntry(item.id, (currentItem) => { - const next = { ...currentItem }; - delete next.repository; - return next; - }); - } - return (
@@ -307,8 +342,6 @@ export function ContributionItemsEditor({ {value.items.map((item, index) => { const controlId = `${baseId}-entry-${index}`; const links = editorLinks(item); - const repository = item.repository; - const repositoryTarget = repository ? parseGitHubRepositoryUrl(repository.url) : null; const itemTitle = item.title.trim() || t("entry", { number: index + 1 }); const linkSectionPath = ["data", "contributions", "items", index] as const; return ( @@ -363,7 +396,9 @@ export function ContributionItemsEditor({ onChange={(event) => updateEntry(item.id, (currentItem) => ({ ...currentItem, - description: event.target.value, + description: item.repository?.description + ? mergeDescriptions(event.target.value, item.repository.description) + : event.target.value, })) } /> @@ -375,9 +410,12 @@ export function ContributionItemsEditor({

{t("linksHelp")}

{links.map(({ link, legacy }, linkIndex) => { - const urlPath = legacy - ? [...linkSectionPath, "url"] - : [...linkSectionPath, "links", linkIndex, "url"]; + const urlPath = + legacy === "url" + ? [...linkSectionPath, "url"] + : legacy === "repository" + ? [...linkSectionPath, "repository", "url"] + : [...linkSectionPath, "links", linkIndex, "url"]; return (
@@ -397,7 +435,7 @@ export function ContributionItemsEditor({ = 5} + disabled={links.length >= contributionLinksMax} aria-describedby={`${controlId}-link-limit`} onClick={() => addLink(item)} > @@ -534,77 +572,6 @@ export function ContributionItemsEditor({
- {repository ? ( -
- - {t("repositoryTitle")} - -
-

{t("repositoryHelp")}

- - updateRepository(item, "url", event.target.value)} - /> - - -