From 0a272c4ac3376c73954ae536db1e2e065e4f4193 Mon Sep 17 00:00:00 2001 From: Juan Nicolas Herrera <54152074+juanNH@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:21:14 -0300 Subject: [PATCH] feat: add round-trip README recovery --- docs/README.md | 1 + docs/architecture.md | 8 +- .../0018-readme-import-with-backup.md | 2 +- .../0021-project-files-and-recovery.md | 2 +- ...0044-readme-roundtrip-and-guided-import.md | 36 ++ docs/editor.md | 8 +- docs/profile-model.md | 8 +- src/core/import/index.ts | 6 + src/core/import/readme-import-types.ts | 79 +++- src/core/import/readme-import.test.ts | 181 +++++++- src/core/import/readme-import.ts | 305 ++++++++++++-- src/core/import/readme-module-proposals.ts | 385 ++++++++++++++++++ src/core/readme/generate-readme.ts | 76 +++- src/core/readme/index.ts | 15 + src/core/readme/readme-manifest.test.ts | 183 +++++++++ src/core/readme/readme-manifest.ts | 273 +++++++++++++ src/core/readme/readme-result.ts | 4 +- src/features/editor/editor-workspace.tsx | 99 ++++- src/features/editor/readme-import-panel.tsx | 290 ++++++++++--- .../editor/remark-custom-html.test.ts | 20 + src/messages/en/editor.json | 5 + src/messages/en/readme-import.json | 48 ++- src/messages/es/editor.json | 5 + src/messages/es/readme-import.json | 48 ++- tests/e2e/readme-import.spec.ts | 213 +++++++++- 25 files changed, 2153 insertions(+), 147 deletions(-) create mode 100644 docs/decisions/0044-readme-roundtrip-and-guided-import.md create mode 100644 src/core/import/readme-module-proposals.ts create mode 100644 src/core/readme/readme-manifest.test.ts create mode 100644 src/core/readme/readme-manifest.ts diff --git a/docs/README.md b/docs/README.md index 808981e..1cd3992 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,6 +58,7 @@ The GitHub Profile Generator documentation describes the product, its available | [Detailed contributions](decisions/0039-contribution-details-and-order.md) | Multiple links, public images, legacy repository preview compatibility, and contribution order. | | [Activity card columns](decisions/0040-activity-card-columns.md) | Optional one- or two-column card rows and legacy output behavior. | | [Activity and contribution order](decisions/0041-activity-contribution-order-and-readable-cards.md) | Choose content order and keep two-column card text readable. | +| [README round-trip and guided import](decisions/0044-readme-roundtrip-and-guided-import.md) | Hidden versioned structure, explicit full-profile recovery, and reviewed module suggestions. | | [Third-party notices](../THIRD_PARTY_NOTICES.md) | Provenance and licenses for icons, flags, the code editor, and Extended cards. | | [Contributing](../CONTRIBUTING.md) | Workflow, expectations for changes, and pull requests. | diff --git a/docs/architecture.md b/docs/architecture.md index ad55689..b319c00 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ Custom-section operations add, update, include, duplicate, and delete instances. Hiding a section preserves its data; reordering also preserves hidden and retired IDs. Public activity does not determine a person's skills. Document validation checks the structure and invariants of the full draft without evaluating output safety. -`generateReadme` receives a valid v2 document and an optional explicit card origin. It returns `ReadmeResult`: Markdown with optional `exportIssues`, `customImages`, and `customHtml`, or errors with a code and path. It walks `sectionOrder` and uses pure renderers for visible fixed or custom sections; it composes activity into contributions and omits metrics as a separate output. It omits empty entries and headings without useful body content. Partial content is allowed without requiring a name, job, or repository. Generated labels use `contentLocale` while personal text is retained. +`generateReadme` receives a valid v2 document and optional explicit export settings. The editor requests a terminal, versioned comment manifest on the one Markdown result used by preview, copy, and download. It identifies emitted fixed/custom sections and their order without including hidden field values. An explicit opt-in adds the complete v2 snapshot to that same README; it includes hidden and retired data and is public in the raw source. The generator does not read network or storage. It walks `sectionOrder` and uses pure renderers for visible fixed or custom sections; it composes activity into contributions and omits metrics as a separate output. It omits empty entries and headings without useful body content. Partial content is allowed without requiring a name, job, or repository. Generated labels use `contentLocale` while personal text is retained. `render-custom.ts` escapes titles and ordinary text, and generates paragraphs, ordered/unordered lists, HTTP/HTTPS links, subheadings, dividers, quotes, explicit images, literal code, and filtered HTML units. Without a chosen format, a subheading uses `###` when the section has a title and `##` otherwise; a divider uses `---` and a quote prefixes all its lines with `>`. Empty list items are omitted and continuations are indented to match marker width, including two-digit numbering. A generator-local index alternates equivalent markers between emitted lists so CommonMark retains separate lists, including in untitled sections; there is no global state. Errors point to `customSections` and `blocks` indices, independent of visual order. A hidden instance does not participate in output, metadata, or export validation. List-writing helpers belong only to paragraphs and lists in `modules/custom`; fixed forms still do not interpret Markdown. @@ -156,13 +156,13 @@ The custom form shows code, help, warnings, problems, and inspectable exported H ## Local README import -HTML heading detection uses `readme-import-html.ts`: original HAST provides text and ranges; a bounded scanner retains context across nodes and confirms explicit closing tags before proposing a root-level split. Initial headings without a body stay together and do not determine later hierarchy. Segments retain exact UTF-16 slices and their review label, without adding a duplicate title to the HTML or changing v2. Uncertainty keeps the content together; modules are not classified by tags or URLs. +HTML heading detection uses `readme-import-html.ts`: original HAST provides text and ranges; a bounded scanner retains context across nodes and confirms explicit closing tags before proposing a root-level split. Initial headings without a body stay together and do not determine later hierarchy. Segments retain exact UTF-16 slices and their review label without adding duplicate headings. The app manifest identifies each unchanged section's original destination; an adapter returns it to a fixed module only when its fields can be reconstructed safely, otherwise the content stays custom. An optional validated snapshot restores the full profile; if its visible Markdown was edited, review requires choosing between that snapshot and the current body. -`src/core/import/` analyzes the entire document with unified, remark-parse, and remark-gfm; it uses mdast-util-to-hast and the existing HTML analyzer for compositions that do not fit visual blocks. It is pure, without React, network, DOM, or storage. `analyzeReadmeImport` returns source, ranges, sections, and diagnostics; `buildImportedProfile` validates the selection, builds v2 with custom sections, and checks the generator. References are resolved before splitting; HTML containers retain their context. The contract is not extended with raw Markdown, and modules are not assigned through heuristics. +`src/core/import/` analyzes the complete document with unified, remark-parse, and remark-gfm; it uses mdast-util-to-hast and the existing HTML analyzer for compositions that do not fit visual blocks. It is pure, without React, network, DOM, or storage. `analyzeReadmeImport` returns source, ranges, per-section fixed-module suggestions, custom fallbacks, recovery state, and diagnostics; `buildImportedProfile` validates explicit choices and checks the generator. The manifest identifies the original destination for unchanged emitted sections, while a module adapter maps content only when it can reconstruct the fixed fields safely; otherwise it retains the content as custom with a hint. Without a full snapshot, not every rendered module is reversible. Suggestions for other README files use heading aliases plus content structure, never a title alone. References are resolved before splitting; HTML containers retain their context. The contract is not extended with raw Markdown. Visible body import remains limited to 100,000 UTF-16 units, and a manifest-bearing file is limited to 200,000 units. The panel is loaded on demand when opened from the editor, together with the parser. It keeps source, selection, and review in memory across language changes; confirmation is never resumed automatically. Originals and output are displayed as React code, without mounting remote resources. Editing source invalidates analysis, and late file-reading responses are discarded. The usual preview is used after the profile is applied. -`import-backup.ts` validates a sidecar `github-profile-generator.import-backup.v1` record with the complete previous profile and original source. The hook writes that record before replacing the draft and offers restoration without overwriting it. Synchronous review of state, `dirty`, and protection flags prevent an old debounce, `pagehide`, or unmount from restoring the previous profile over the imported one. Invalid or inaccessible values are not replaced: continuing in memory requires an explicit action and does not enable autosave. Downloadable JSON configuration can preserve the previous profile; opening JSON files belongs to the separate [project files](#project-files) flow. See [ADR 0018](decisions/0018-readme-import-with-backup.md). +`import-backup.ts` validates a sidecar `github-profile-generator.import-backup.v1` record with the complete previous profile and exact original source. The hook writes that record before replacing the draft and offers restoration without overwriting it. Synchronous review of state, `dirty`, and protection flags prevent an old debounce, `pagehide`, or unmount from restoring the previous profile over the imported one. Invalid or inaccessible values are not replaced: continuing in memory requires an explicit action and does not enable autosave. Project JSON remains a separate complete backup/import option; it is not a companion file required for README round-tripping. See [ADR 0018](decisions/0018-readme-import-with-backup.md) and [ADR 0044](decisions/0044-readme-roundtrip-and-guided-import.md). ## Public GitHub activity diff --git a/docs/decisions/0018-readme-import-with-backup.md b/docs/decisions/0018-readme-import-with-backup.md index 67cd8a3..f7f78d6 100644 --- a/docs/decisions/0018-readme-import-with-backup.md +++ b/docs/decisions/0018-readme-import-with-backup.md @@ -1,6 +1,6 @@ # ADR 0018: README Import with Review and Backup -- Status: accepted for the first import increment. +- Status: accepted for the first import increment; current module mapping is superseded by ADR 0044. - Date: 2026-10-20. - Complements ADRs 0004, 0013, 0015, and 0017. diff --git a/docs/decisions/0021-project-files-and-recovery.md b/docs/decisions/0021-project-files-and-recovery.md index f90f32a..afbf416 100644 --- a/docs/decisions/0021-project-files-and-recovery.md +++ b/docs/decisions/0021-project-files-and-recovery.md @@ -1,6 +1,6 @@ # ADR 0021: Project Files and Independent Recovery -- Status: accepted. +- Status: accepted; README recovery metadata is superseded by ADR 0044. - Date: 2026-10-20. ## Context diff --git a/docs/decisions/0044-readme-roundtrip-and-guided-import.md b/docs/decisions/0044-readme-roundtrip-and-guided-import.md new file mode 100644 index 0000000..587e14e --- /dev/null +++ b/docs/decisions/0044-readme-roundtrip-and-guided-import.md @@ -0,0 +1,36 @@ +# ADR 0044: README Round-Trip Metadata and Guided Import + +- Status: accepted. +- Date: 2026-10-23. +- Supersedes the custom-only mapping decision in ADR 0018 and the rejection of README recovery metadata in ADR 0021. + +## Context + +The generated README is the file people publish and share, but visible Markdown does not identify the fixed modules that produced it and omits hidden profile data and editor settings. The existing project JSON preserves the complete `ProfileDocument`, but requiring a README and a second JSON file does not provide the desired one-file download/import flow. Arbitrary README files also vary too much for reliable classification from headings alone. + +## Decision + +- Keep `ProfileDocument` v2 as the persisted model and the existing project JSON flow as a separate full-fidelity option. Do not add manifest fields to the profile model. +- Append one exact, versioned HTML-comment manifest to app-generated Markdown. The manifest records emitted section identities, order, UTF-16 ranges, and a body fingerprint. It contains no hidden profile values by default. The fingerprint detects changed source only; it is not an authenticity check. +- Offer an explicit export choice to include the complete validated v2 profile snapshot in that same README. Clearly disclose that this includes hidden and retired data and is readable in the raw public source even though GitHub hides the comment from its rendered Markdown. Do not include the snapshot unless selected. +- Bound the visible README body to 100,000 UTF-16 code units and the complete manifest-bearing file to 200,000 units. Never truncate. If a requested full snapshot cannot fit or validate, block that export and direct the person to the separate project JSON option. +- On import, recognize only the exact terminal manifest syntax and validate it as untrusted data before using it. A matching full snapshot offers exact restoration from the same `.md`; after edits to the visible body, let the person choose between restoring the captured snapshot and importing the current Markdown. Structural metadata may guide section-to-module routing only when the body matches. Invalid, unsupported, or absent metadata produces a notice and falls back to the normal Markdown review without discarding the source. +- For ordinary or edited README files, use deterministic module-specific adapters supported by both heading aliases and content structure. Show the proposed destination, recognized fields, and conversion notices. Consume a heading used to identify a fixed module instead of duplicating it as a custom title. Ambiguous, repeated, partially supported, or unknown content remains recoverable as custom content or requires an explicit choice. +- Preserve the existing local-only analysis, HTML/URL policies, source ranges, inert review, explicit replacement confirmation, backup-before-write behavior, and protected-storage handling. Do not add AI, network calls, raw Markdown blocks, services, or dependencies. + +## Alternatives + +- Put the full snapshot in every README by default: rejected because hidden profile values would be exposed in public raw source without an explicit choice. +- Keep JSON as the only exact recovery route: retained as an option, but insufficient for the requested one-file README round trip. +- Infer fixed modules from titles alone: rejected because titles are ambiguous and do not establish that the content maps to the module's fields. +- Send arbitrary Markdown to an AI service: rejected because deterministic local conversion and explicit review meet the need without adding uncertainty, accounts, or network data handling. + +## Consequences + +People who opt in can download one README and later restore the exact captured profile by importing that same file. The opt-in README can expose data not displayed on the GitHub profile; the interface must explain this before export. People who decline the snapshot can still reimport the visible structure, while project JSON remains the complete alternative. Edited, third-party, stale, or unsupported Markdown is best-effort and must state what was recognized, converted, or retained as custom content. Future active modules need an import adapter when their data shape changes. + +The manifest is part of the generated Markdown consumed by preview, copy, and download. It is not authored HTML and does not broaden the custom HTML allowlist. Older README files without the marker continue through the existing parser and review flow. + +## Review conditions + +Revisit the manifest version and size limits when the profile contract or README importer limits change. Never treat the public comment as secret storage or accept an unsupported snapshot without preserving the original file for review. diff --git a/docs/editor.md b/docs/editor.md index ad1de7c..fb2298a 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -29,7 +29,7 @@ The current profile must also fit the format so it can be backed up before openi 3. Use each section's checkbox to include or hide it. The checkbox preserves focus and content; by itself, it does not change the form you are editing. The active section is marked. Available sections follow at the end of the row with a distinct state. 4. Reorder included sections from their handle in the same row, using a pointer, touch or keyboard. Space or Enter picks up and drops; the left/right arrows move, and Escape cancels without saving the provisional order. You can also select a section and use the bar's two ordering arrows. You can add and remove entries; adding focuses the new entry, and removing one focuses a neighbor or a stable action. Removing an entry deletes its data from the draft. 5. Choose the README language in the settings header, above the title and form. This changes generated labels such as “Contact” or “Projects”; it does not translate your text. The interface language selector is independent. -6. Review the preview and notices. On wide screens it is to the right of settings. If there is enough height, each column scrolls independently using the wheel, keyboard or its scrollbar; focusing a field brings it into view. Scrollbars use the editor's style and respect forced colors. On narrow or short screens, or with enlarged text, natural page scrolling is preserved; switch between Edit and Preview from the top bar. When there is valid visible content, copy the Markdown or download `README.md`. Both actions use the same content as the preview. See [ADR 0028](decisions/0028-independent-editor-scroll.md). +6. Review the preview and notices. On wide screens it is to the right of settings. If there is enough height, each column scrolls independently using the wheel, keyboard or its scrollbar; focusing a field brings it into view. Scrollbars use the editor's style and respect forced colors. On narrow or short screens, or with enlarged text, natural page scrolling is preserved; switch between Edit and Preview from the top bar. When there is valid visible content, copy the Markdown or download `README.md`. Both actions use the same content as the preview. App-generated Markdown carries invisible section metadata. In export settings, **Include full recovery data** adds the complete profile snapshot to this same file, so you can later import the `.md` alone and restore the profile. This is optional because the comment is visible in the raw public source and may contain hidden or retired content. Without it, the README can still be reimported structurally, but cannot restore hidden data or every editor setting. Project JSON is a separate complete backup option. See [ADR 0028](decisions/0028-independent-editor-scroll.md) and [ADR 0044](decisions/0044-readme-roundtrip-and-guided-import.md). 7. To publish it, follow the profile-repository instructions next to the export controls. The app does not publish to or modify GitHub for you. A short profile can be ready with a single section. You do not need a name, job, project, repository or public activity to say something about yourself. The introduction can mention interests, learning and current activity without repeating them in different forms. Copy and download stay disabled when there is no exportable content. @@ -52,15 +52,15 @@ On touch devices, the button is hidden to keep the keyboard's picker accessible. 4. Review the confirmation and check that you understand the profile replacement. The complete previous profile and original Markdown are saved before applying it. Closing, going back, analyzing or changing a checkbox does not replace the draft. 5. The imported profile opens in the editor. Return to **Import README** and expand the backup to download `previous-profile.json` or `imported-original.md`, or restore the previous profile with a new confirmation. The backup remains available after restoration. -This version creates **custom sections** in their reading order. It does not automatically assign content to the introduction, technologies, projects or contact. The published README does not contain identities, hidden sections or all editor settings; importing it cannot reconstruct that information. Recover the previous configuration from the backup, not from the Markdown. +For an unchanged README generated by this app, the manifest identifies its original fixed/custom sections. If the file contains the optional full recovery snapshot, review offers exact profile restoration from that same `.md`. If its visible content has changed, choose whether to restore the captured profile or import the edited Markdown. Older app-generated files and external READMEs use best-effort suggestions: heading aliases and content structure propose active modules, while uncertain or unsupported material stays custom or requires a choice. Titles alone do not determine module identity, and the review explains what was recognized or converted. Paragraphs, lists and other simple content are converted to visual blocks when their content can be preserved. Fenced or indented code blocks are imported as literal code with their language when representable. Rich formatting, tables or nested lists may be converted to allowed HTML, with a notice. Global references are resolved before sections are split; an HTML container that crosses boundaries may keep the document grouped to preserve its context. Visual parity with GitHub is not guaranteed. Extra code metadata is omitted with a notice; task lists and other incompatible formats need correction or exclusion. Relative paths and anchors must be changed to accepted URLs; the source repository is not guessed. For HTML-based READMEs, complete root-level headings are detected and used to name the review sections. A name followed by an opening phrase stays together even if they use different heading levels. H2 dividers define the hierarchy; when there are none, H3 headings may be used to split sections. Headings inside a table, disclosure or container remain within that content. If a split cannot be determined with confidence, the content is kept as one custom HTML section. External statistics images remain images where the author placed them; they do not activate cards or trigger queries. Unsupported attributes still produce notices. -The input limit is 100,000 UTF-16 code units and the file cannot exceed 400,000 bytes. There are also node and depth limits, in addition to the HTML limits. The file must be UTF-8; BOM and CRLF line endings are preserved in the original. Images and links follow the editor's policies: an invalid URL or missing alternative description must be reviewed before applying. +The visible Markdown body limit is 100,000 UTF-16 code units; an app manifest can add up to 100,000 more, for a 200,000-unit file limit. The UTF-8 file limit is 800,000 bytes. There are also node and depth limits, in addition to the HTML limits. The file must be UTF-8; BOM and CRLF line endings are preserved in the original. Images and links follow the editor's policies: an invalid URL or missing alternative description must be reviewed before applying. -There is **one latest README-import backup** for this browser and origin. If storage fails or contains a protected value, the import is not applied automatically; an additional action lets you continue in memory only. This choice preserves stored data and disables autosaving the imported profile; download the backups and project before reloading or closing. There is no remote history. This importer processes Markdown; to open a JSON profile, use [Save and open projects](#save-and-open-projects). See the [import decision](decisions/0018-readme-import-with-backup.md). +There is **one latest README-import backup** for this browser and origin. If storage fails or contains a protected value, the import is not applied automatically; an additional action lets you continue in memory only. This choice preserves stored data and disables autosaving the imported profile; download the backup before reloading or closing. There is no remote history. To work with a complete project JSON instead, use [Save and open projects](#save-and-open-projects); it is independent of README import. See the [import decision](decisions/0018-readme-import-with-backup.md) and [round-trip decision](decisions/0044-readme-roundtrip-and-guided-import.md). ## Content and composition diff --git a/docs/profile-model.md b/docs/profile-model.md index 77dc327..bddfbc0 100644 --- a/docs/profile-model.md +++ b/docs/profile-model.md @@ -204,11 +204,13 @@ Operations require a valid document produced by the factory or parser and valida ## Import from Markdown -The importer does not change the v2 schema. `analyzeReadmeImport(source, contentLocale)` analyzes the complete Markdown source and returns candidate sections, UTF-16 ranges for the exact source, and diagnostics. `buildImportedProfile(analysis, selectedSectionIds)` accepts valid identities from that proposal, preserves the original order, and returns a validated and generated document or errors without partial replacement. Imported sections are custom; fixed references remain present and hidden. Hidden data from the previous profile is not mixed with imported content. +The importer does not change the v2 `ProfileDocument` schema. App-generated Markdown includes an out-of-model versioned comment manifest with emitted fixed/custom section references, order, ranges, and a source fingerprint. An explicit export option can add the complete validated v2 document for single-file exact recovery; this exposes hidden and retired data in the raw public source. The importer validates the manifest and snapshot before review. Project JSON remains a separate option. -Blocks reuse `paragraph`, `list`, `link`, `heading`, `divider`, `quote`, `image`, `html`, and `code`; there is no raw Markdown block. Independent fenced or indented code preserves its AST source and normalized language; metadata that cannot be represented produces a notice. Within an open HTML context or a nested structure that requires preserving contiguous HTML, that structure takes precedence and loss of language metadata is reported. Converted HTML follows the existing policy. Analysis allows 100,000 UTF-16 code units, 6,000 nodes, and a depth of 32; HTML and code retain their own limits. References, anchors, relative paths, and incompatible constructs are checked before applying the proposal. The backup preserves the exact original even if the parser normalizes line breaks in a code block. +`analyzeReadmeImport(source, contentLocale)` analyzes the complete Markdown source and returns candidate sections, UTF-16 ranges, module suggestions, recovery state, and diagnostics. App-authored metadata identifies the original destination of unchanged sections; a module adapter restores fixed fields only when it can reconstruct them safely, otherwise it keeps the visible content as a custom section with a hint. Without a full snapshot, not every rendered module is reversible. For other or edited READMEs, deterministic title aliases and content-shape adapters make explicit proposals. Fixed headings are not duplicated as custom titles. No data from the existing profile is silently merged. Snapshot restoration and importing edited visible content are separate explicit outcomes. -The adapter's backup record is outside `ProfileDocument`: `{ schemaVersion: 1, previousDocument, originalSource }`. Strict reading does not write or migrate incompatible records. It preserves one complete previous profile and the original source independently of the selection. Downloads do not imply a general JSON configuration import API. See [ADR 0018](decisions/0018-readme-import-with-backup.md). +Blocks reuse `paragraph`, `list`, `link`, `heading`, `divider`, `quote`, `image`, `html`, and `code`; there is no raw Markdown block. Independent fenced or indented code preserves its AST source and normalized language; metadata that cannot be represented produces a notice. Within an open HTML context or a nested structure that requires preserving contiguous HTML, that structure takes precedence and loss of language metadata is reported. Converted HTML follows the existing policy. Visible Markdown analysis allows 100,000 UTF-16 code units, 6,000 nodes, and a depth of 32; a manifest-bearing file allows 200,000 units. HTML and code retain their own limits. References, anchors, relative paths, and incompatible constructs are checked before applying the proposal. The backup preserves the exact original even if the parser normalizes line breaks in a code block. + +The adapter's backup record is outside `ProfileDocument`: `{ schemaVersion: 1, previousDocument, originalSource }`. Strict reading does not write or migrate incompatible records. It preserves one complete previous profile and the original source independently of the selection. README manifest data is derived export metadata and does not change persisted keys or structures. See [ADR 0018](decisions/0018-readme-import-with-backup.md) and [ADR 0044](decisions/0044-readme-roundtrip-and-guided-import.md). ## Validation boundaries diff --git a/src/core/import/index.ts b/src/core/import/index.ts index e63e4e0..5380614 100644 --- a/src/core/import/index.ts +++ b/src/core/import/index.ts @@ -1,12 +1,18 @@ export { analyzeReadmeImport, buildImportedProfile } from "./readme-import"; export { README_IMPORT_MAX_LENGTH, + README_IMPORT_MAX_SOURCE_LENGTH, README_IMPORT_MAX_NODES, README_IMPORT_MAX_DEPTH, type ReadmeImportAnalysis, type ReadmeImportDiagnostic, type ReadmeImportNotice, type ReadmeImportNoticeCode, + type ReadmeImportModuleProposal, + type ReadmeImportModuleHint, + type ReadmeImportRecovery, + type ReadmeImportMode, + type ReadmeImportBuildOptions, type ReadmeImportProblem, type ReadmeImportProblemCode, type ReadmeImportSection, diff --git a/src/core/import/readme-import-types.ts b/src/core/import/readme-import-types.ts index b27e546..838794e 100644 --- a/src/core/import/readme-import-types.ts +++ b/src/core/import/readme-import-types.ts @@ -1,8 +1,11 @@ import type { CustomBlock } from "../profile/custom-content"; -import type { ContentLocale, ProfileDocument } from "../profile/profile-schema"; -import type { ReadmeResult } from "../readme/readme-result"; +import type { ProfileModuleData } from "../profile/module-content"; +import type { ContentLocale, ProfileDocument, SectionReference } from "../profile/profile-schema"; +import { README_MANIFEST_MAX_SOURCE_LENGTH } from "../readme/readme-manifest"; +import type { ReadmeIssue, ReadmeResult } from "../readme/readme-result"; export const README_IMPORT_MAX_LENGTH = 100_000; +export const README_IMPORT_MAX_SOURCE_LENGTH = README_MANIFEST_MAX_SOURCE_LENGTH; export const README_IMPORT_MAX_NODES = 6_000; export const README_IMPORT_MAX_DEPTH = 32; @@ -21,6 +24,7 @@ export type ReadmeImportProblemCode = | "no_sections_selected" | "invalid_selection" | "invalid_document" + | "snapshot_unavailable" | "generation_failed"; export type ReadmeImportNoticeCode = @@ -33,7 +37,12 @@ export type ReadmeImportNoticeCode = | "html_unsupported_elements" | "html_unsupported_attributes" | "html_comments_removed" - | "html_repaired_html"; + | "html_repaired_html" + | "module_mapping_not_confirmed" + | "module_mapping_conflict" + | "manifest_invalid" + | "manifest_unsupported" + | "manifest_stale"; /** Positions address the exact original source in UTF-16 units, never generated HTML. */ export type ReadmeImportDiagnostic = { @@ -51,6 +60,55 @@ export type ReadmeImportNotice = ReadmeImportDiagnostic & { readonly code: ReadmeImportNoticeCode; }; +export type ReadmeImportModuleProposal = + | { + readonly moduleId: "presentation"; + readonly confidence: "strong"; + readonly reason: "profile_header"; + readonly data: ProfileModuleData["presentation"]; + } + | { + readonly moduleId: "technologies"; + readonly confidence: "strong"; + readonly reason: "technology_catalog_list"; + readonly data: ProfileModuleData["technologies"]; + } + | { + readonly moduleId: "projects"; + readonly confidence: "strong"; + readonly reason: "project_link_list"; + readonly data: ProfileModuleData["projects"]; + } + | { + readonly moduleId: "contributions"; + readonly confidence: "strong"; + readonly reason: "contribution_link_list"; + readonly data: ProfileModuleData["contributions"]; + } + | { + readonly moduleId: "contact"; + readonly confidence: "strong"; + readonly reason: "contact_links"; + readonly data: ProfileModuleData["contact"]; + }; + +export type ReadmeImportRecovery = + | { readonly kind: "none" } + | { readonly kind: "structural"; readonly matchesSource: boolean } + | { + readonly kind: "snapshot"; + readonly snapshot: ProfileDocument; + readonly matchesSource: boolean; + } + | { readonly kind: "invalid" } + | { readonly kind: "unsupported"; readonly version: number }; + +export type ReadmeImportModuleHint = { + readonly moduleId: ReadmeImportModuleProposal["moduleId"]; + readonly confidence: "review"; + readonly reason: "manifest_reference" | "heading_alias"; +}; + export type ReadmeImportSection = { readonly id: string; /** A review/navigation label; it does not imply a fixed module identity. */ @@ -63,6 +121,9 @@ export type ReadmeImportSection = { readonly representation: "visual" | "html" | "mixed" | "blocked"; readonly notices: readonly ReadmeImportNotice[]; readonly errors: readonly ReadmeImportProblem[]; + readonly moduleProposal?: ReadmeImportModuleProposal; + readonly moduleHint?: ReadmeImportModuleHint; + readonly manifestReference?: SectionReference; }; export type ReadmeImportAnalysis = { @@ -71,13 +132,25 @@ export type ReadmeImportAnalysis = { readonly sections: readonly ReadmeImportSection[]; readonly notices: readonly ReadmeImportNotice[]; readonly errors: readonly ReadmeImportProblem[]; + readonly recovery: ReadmeImportRecovery; +}; + +export type ReadmeImportMode = "markdown" | "snapshot"; + +export type ReadmeImportBuildOptions = { + readonly mode?: ReadmeImportMode; + readonly destinationOverrides?: Readonly< + Record + >; }; export type ImportedProfileResult = | { readonly success: true; + readonly mode: ReadmeImportMode; readonly document: ProfileDocument; readonly readme: Extract; + readonly readmeErrors?: readonly ReadmeIssue[]; readonly excludedSectionIds: readonly string[]; } | { readonly success: false; readonly errors: readonly ReadmeImportProblem[] }; diff --git a/src/core/import/readme-import.test.ts b/src/core/import/readme-import.test.ts index 99fb32b..194ebd1 100644 --- a/src/core/import/readme-import.test.ts +++ b/src/core/import/readme-import.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { createEmptyProfile } from "../profile/profile-operations"; +import { + addCustomSection, + createEmptyProfile, + updateCustomSection, +} from "../profile/profile-operations"; import { parseProfileDocument } from "../profile/profile-validation"; import { generateReadme } from "../readme/generate-readme"; import { @@ -26,7 +30,7 @@ function imported(source: string) { } describe("local README import analysis", () => { - it("creates an ordered proposal without assuming the fixed identity of headings", () => { + it("maps fixed modules only when heading and content shape both agree", () => { const source = "# Hi, I am Alex\n\nI build useful tools.\n\n## Technologies\n\nReact and Rust.\n\n## Contact\n\n[Portfolio](https://alex.dev)"; const { analysis, result } = imported(source); @@ -36,17 +40,184 @@ describe("local README import analysis", () => { "Contact", ]); expect(analysis.sections.map((section) => section.source).join("")).toBe(source); - expect(result.document.customSections).toHaveLength(3); - expect(Object.values(result.document.visibility).every((visible) => !visible)).toBe(true); + expect(result.document.customSections).toHaveLength(1); + expect(result.document.customSections[0]?.title).toBe("Technologies"); + expect(result.document.visibility.presentation).toBe(true); + expect(result.document.visibility.contact).toBe(true); + expect(result.document.visibility.technologies).toBe(false); expect(result.document.data.metrics.cards).toEqual([]); expect( result.document.sectionOrder.filter((section) => section.kind === "module"), ).toHaveLength(9); expect(parseProfileDocument(result.document).success).toBe(true); - expect(result.readme.markdown).toContain("

Hi, I am Alex

"); + expect(result.document.data.presentation).toMatchObject({ + displayName: "Alex", + bio: "I build useful tools.", + }); + expect(result.document.data.contact.links).toMatchObject([ + { label: "Portfolio", url: "https://alex.dev/" }, + ]); + expect(result.readme.markdown).toContain("Alex"); expect(result.document.data.technologies).toEqual(createEmptyProfile("en").data.technologies); }); + it("does not map a fixed-looking title without compatible content", () => { + const { analysis, result } = imported("## Contact\n\nWays to get in touch are coming soon."); + expect(analysis.sections[0]?.moduleProposal).toBeUndefined(); + expect(analysis.sections[0]?.notices).toContainEqual( + expect.objectContaining({ code: "module_mapping_not_confirmed" }), + ); + expect(result.document.visibility.contact).toBe(false); + expect(result.document.customSections[0]?.title).toBe("Contact"); + }); + + it("maps a complete project link list and keeps repeated module candidates custom", () => { + const source = + "## Projects\n\n- [Widget](https://github.com/alex/widget) — A small tool\n\n## Projects\n\n- [Parser](https://github.com/alex/parser) — A parser"; + const analysis = analyzeReadmeImport(source, "en"); + expect(analysis.sections.map((section) => section.moduleProposal)).toEqual([ + undefined, + undefined, + ]); + expect( + analysis.sections.every((section) => + section.notices.some((notice) => notice.code === "module_mapping_conflict"), + ), + ).toBe(true); + expect(analysis.sections.map((section) => section.moduleHint?.moduleId)).toEqual([ + "projects", + "projects", + ]); + const result = buildAll(analysis); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.document.visibility.projects).toBe(false); + expect(result.document.customSections).toHaveLength(2); + }); + + it("supports keeping a strong module proposal as a custom section", () => { + const analysis = analyzeReadmeImport("## Contact\n\n[Portfolio](https://alex.dev)", "en"); + expect(analysis.sections[0]?.moduleProposal?.moduleId).toBe("contact"); + const result = buildImportedProfile(analysis, [analysis.sections[0]!.id], { + destinationOverrides: { [analysis.sections[0]!.id]: "custom" }, + }); + expect(result).toMatchObject({ + success: true, + document: { visibility: { contact: false }, customSections: [{ title: "Contact" }] }, + }); + }); + + it("uses current structural references for app-authored Markdown", () => { + const contact = createEmptyProfile("en"); + contact.visibility.contact = true; + contact.data.contact.links = [ + { id: "contact-link", label: "Portfolio", url: "https://alex.dev" }, + ]; + const generatedModule = generateReadme(contact, { includeReadmeManifest: true }); + expect(generatedModule.success).toBe(true); + if (!generatedModule.success) return; + + const moduleAnalysis = analyzeReadmeImport(generatedModule.markdown, "en"); + expect(moduleAnalysis.recovery).toEqual({ kind: "structural", matchesSource: true }); + expect(moduleAnalysis.sections[0]?.manifestReference).toEqual({ + kind: "module", + moduleId: "contact", + }); + expect(moduleAnalysis.sections[0]?.moduleProposal?.moduleId).toBe("contact"); + + const presentation = createEmptyProfile("en"); + presentation.visibility.presentation = true; + presentation.data.presentation.displayName = "Alex"; + const generatedPresentation = generateReadme(presentation, { includeReadmeManifest: true }); + expect(generatedPresentation.success).toBe(true); + if (!generatedPresentation.success) return; + const presentationAnalysis = analyzeReadmeImport(generatedPresentation.markdown, "en"); + expect(presentationAnalysis.sections[0]?.moduleProposal).toBeUndefined(); + expect(presentationAnalysis.sections[0]?.moduleHint).toEqual({ + moduleId: "presentation", + confidence: "review", + reason: "manifest_reference", + }); + expect(presentationAnalysis.sections[0]?.notices).toContainEqual( + expect.objectContaining({ code: "module_mapping_not_confirmed" }), + ); + + const added = addCustomSection(createEmptyProfile("en"), () => "custom-contact"); + expect(added.success).toBe(true); + if (!added.success) return; + const updated = updateCustomSection(added.data, "custom-contact", { + id: "custom-contact", + title: "Contact", + visible: true, + blocks: [{ id: "contact-copy", type: "paragraph", text: "Reach me here." }], + }); + expect(updated.success).toBe(true); + if (!updated.success) return; + const generatedCustom = generateReadme(updated.data, { includeReadmeManifest: true }); + expect(generatedCustom.success).toBe(true); + if (!generatedCustom.success) return; + + const customAnalysis = analyzeReadmeImport(generatedCustom.markdown, "en"); + expect(customAnalysis.sections[0]?.manifestReference).toEqual({ + kind: "custom", + sectionId: "custom-contact", + }); + expect(customAnalysis.sections[0]?.moduleProposal).toBeUndefined(); + }); + + it("ignores stale structural ranges and offers a valid snapshot past the Markdown cap", () => { + const profile = createEmptyProfile("en"); + profile.visibility.presentation = true; + profile.data.presentation.displayName = "Alex"; + const generated = generateReadme(profile, { + includeReadmeManifest: true, + includeRecoverySnapshot: true, + }); + expect(generated.success).toBe(true); + if (!generated.success) return; + + const markerIndex = generated.markdown.indexOf("\n")[0]; + expect(payload).toBeDefined(); + expect(payload).not.toContain("--"); + expect(parsed.manifest.sections).toEqual([ + expect.objectContaining({ kind: "module", moduleId: "presentation" }), + ]); + }); + + it("does not add metadata unless the caller opts in", () => { + const profile = createEmptyProfile("en"); + profile.visibility.presentation = true; + profile.data.presentation.displayName = "Alex"; + const plain = generateReadme(profile); + const marked = generateReadme(profile, { includeReadmeManifest: true }); + + expect(plain.success).toBe(true); + expect(marked.success).toBe(true); + if (!plain.success || !marked.success) return; + expect(parseReadmeManifest(plain.markdown).status).toBe("absent"); + const metadata = parseReadmeManifest(marked.markdown); + expect(metadata.status).toBe("valid"); + if (metadata.status === "valid") { + expect(metadata.markdown).toBe(plain.markdown); + expect(metadata.manifest.sections[0]?.start).toBe(0); + expect(metadata.manifest.sections[0]?.end).toBe(plain.markdown.length - 1); + } + expect(marked.customHtml).toEqual(plain.customHtml); + }); + + it("rejects an invalid embedded snapshot while preserving the original README", () => { + const body = "## Contact\n"; + const payload = JSON.stringify({ + ...manifestFor(body), + recoverySnapshot: { schemaVersion: 2 }, + }); + const source = `${body}\n`; + + expect(parseReadmeManifest(source)).toEqual({ + status: "invalid", + markdown: source, + reason: "invalid_document", + }); + }); + + it("rejects malformed and unsupported terminal markers without stripping the original source", () => { + const body = "## Contact\n"; + const malformed = `${body}\n`; + expect(parseReadmeManifest(malformed)).toEqual({ + status: "invalid", + markdown: malformed, + reason: "malformed", + }); + + const unsupported = `${body}\n`; + expect(parseReadmeManifest(unsupported)).toEqual({ + status: "unsupported", + markdown: unsupported, + version: 2, + }); + }); + + it("bounds the manifest and the complete source without truncating", () => { + const body = `${"x".repeat(README_MANIFEST_MAX_SOURCE_LENGTH)}\n`; + const appended = appendReadmeManifest(body, manifestFor(body)); + expect(appended).toEqual({ success: false, code: "too_large" }); + + const tooLarge = `${"x".repeat(README_MANIFEST_MAX_SOURCE_LENGTH + 1)}\n`; + expect(parseReadmeManifest(tooLarge)).toEqual({ + status: "invalid", + markdown: tooLarge, + reason: "too_large", + }); + expect(README_MANIFEST_MAX_LENGTH).toBeLessThanOrEqual(README_MANIFEST_MAX_SOURCE_LENGTH); + }); + + it("returns an explicit export error when an opted-in profile snapshot exceeds the limit", () => { + const profile: ProfileDocument = createEmptyProfile("en"); + profile.customSections = [ + { + id: "long-code", + title: "Code", + visible: true, + blocks: [0, 1, 2, 3].map((index) => ({ + id: `code-${index}`, + type: "code" as const, + source: "x".repeat(30_000), + language: "", + })), + }, + ]; + profile.sectionOrder.push({ kind: "custom", sectionId: "long-code" }); + + const withoutSnapshot = generateReadme(profile); + const withSnapshot = generateReadme(profile, { includeRecoverySnapshot: true }); + expect(withoutSnapshot.success).toBe(true); + expect(withSnapshot).toEqual({ + success: false, + errors: [{ code: "recovery_manifest_too_large", path: [] }], + }); + }); +}); diff --git a/src/core/readme/readme-manifest.ts b/src/core/readme/readme-manifest.ts new file mode 100644 index 0000000..35a3247 --- /dev/null +++ b/src/core/readme/readme-manifest.ts @@ -0,0 +1,273 @@ +import { + sectionReferenceSchema, + type ProfileDocument, + type SectionReference, +} from "../profile/profile-schema"; +import { parseProfileDocument } from "../profile/profile-validation"; + +export const README_MANIFEST_FORMAT = "github-profile-generator"; +export const README_MANIFEST_VERSION = 1; +export const README_MANIFEST_MAX_LENGTH = 100_000; +export const README_MANIFEST_MAX_SOURCE_LENGTH = 200_000; + +const markerPrefix = "\n`; + if ( + marker.length > README_MANIFEST_MAX_LENGTH || + markdown.length + marker.length > README_MANIFEST_MAX_SOURCE_LENGTH + ) + return { success: false, code: "too_large" }; + return { success: true, markdown: `${markdown}${marker}` }; +} + +/** Parses only the exact terminal manifest. Arbitrary HTML comments are not metadata. */ +export function parseReadmeManifest(source: string): ReadmeManifestParseResult { + const markerIndex = source.lastIndexOf(markerPrefix); + if (markerIndex < 0) return { status: "absent", markdown: source }; + + const original = { markdown: source } as const; + const trailer = source.slice(markerIndex); + if ( + source.length > README_MANIFEST_MAX_SOURCE_LENGTH || + trailer.length > README_MANIFEST_MAX_LENGTH + ) + return { status: "invalid", ...original, reason: "too_large" }; + const header = /^\r?\n?$/.exec( + trailer, + ); + if (!marker || !marker[1]) return { status: "invalid", ...original, reason: "malformed" }; + if (version !== README_MANIFEST_VERSION) return { status: "unsupported", ...original, version }; + if (exceedsJsonDepth(marker[1])) return { status: "invalid", ...original, reason: "malformed" }; + let value: unknown; + try { + value = JSON.parse(marker[1]); + } catch { + return { status: "invalid", ...original, reason: "malformed" }; + } + if (!hasBoundedValues(value)) return { status: "invalid", ...original, reason: "too_large" }; + const manifest = parseManifest(value); + if (!manifest) return { status: "invalid", ...original, reason: "invalid_document" }; + + const markdown = source.slice(0, markerIndex); + return { + status: "valid", + markdown, + manifest, + matchesSource: + markdown.length === manifest.bodyLength && + fingerprintReadmeBody(markdown) === manifest.bodyFingerprint, + }; +} diff --git a/src/core/readme/readme-result.ts b/src/core/readme/readme-result.ts index 0694749..9001186 100644 --- a/src/core/readme/readme-result.ts +++ b/src/core/readme/readme-result.ts @@ -10,7 +10,9 @@ export type ReadmeIssue = { | "invalid_code" | "invalid_username" | "invalid_activity_options" - | "activity_not_configured"; + | "activity_not_configured" + | "readme_manifest_too_large" + | "recovery_manifest_too_large"; readonly path: readonly (string | number)[]; }; diff --git a/src/features/editor/editor-workspace.tsx b/src/features/editor/editor-workspace.tsx index e9f3238..8f15f86 100644 --- a/src/features/editor/editor-workspace.tsx +++ b/src/features/editor/editor-workspace.tsx @@ -21,6 +21,7 @@ import { import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { moduleRegistry } from "@/core/modules/module-registry"; import { addCustomSection, @@ -136,6 +137,9 @@ export function EditorWorkspace({ restoreProjectBackup, } = useProfileDraft(initialContentLocale); const [exportFeedback, setExportFeedback] = useState("idle"); + const [recoverySnapshotProfile, setRecoverySnapshotProfile] = useState( + null, + ); const [selectedId, setSelectedId] = useState(null); const [announcement, setAnnouncement] = useState(""); const [clearFeedback, setClearFeedback] = useState<"saved" | "memory_only" | null>(null); @@ -196,7 +200,12 @@ export function EditorWorkspace({ ? t("custom.untitled", { number: profile.customSections.indexOf(activeCustom) + 1 }) : activeLabel; const isLoading = hydration === "loading"; - const result = generateReadme(profile, { activityCardBaseUrl }); + const includeRecoverySnapshot = recoverySnapshotProfile === profile; + const result = generateReadme(profile, { + activityCardBaseUrl, + includeReadmeManifest: true, + includeRecoverySnapshot, + }); const activityCardSources = result.success ? localActivityCardSources(result.activityCards ?? [], activityCardBaseUrl) : []; @@ -209,6 +218,12 @@ export function EditorWorkspace({ const cardsNeedRefresh = activityData.needsSearch; const readmeErrors = result.success ? [] : result.errors; const isEmpty = readmeErrors.some((error) => error.code === "empty_profile"); + const hasRecoveryManifestError = readmeErrors.some( + (error) => error.code === "recovery_manifest_too_large", + ); + const hasReadmeManifestError = readmeErrors.some( + (error) => error.code === "readme_manifest_too_large", + ); const hasUrlErrors = readmeErrors.some((error) => error.code === "invalid_url"); const hasImageUrlErrors = readmeErrors.some( (error) => @@ -233,23 +248,27 @@ export function EditorWorkspace({ ? t("storage.loading") : isEmpty ? t("export.empty") - : hasInvalidActivityOptions - ? t("export.invalidActivityOptions") - : hasInvalidUsername - ? t("export.invalidUsername") - : hasCodeErrors - ? t("export.invalidCode") - : hasHtmlErrors - ? t("export.invalidHtml") - : hasImageUrlErrors - ? t("export.invalidImages") - : hasMissingImageAlt - ? t("export.missingImageDescription") - : hasUrlErrors - ? t("export.invalidLinks") - : hasLocalCards - ? t("export.localCards") - : t("export.ready"); + : hasRecoveryManifestError + ? t("export.recoverySnapshotTooLarge") + : hasReadmeManifestError + ? t("export.manifestTooLarge") + : hasInvalidActivityOptions + ? t("export.invalidActivityOptions") + : hasInvalidUsername + ? t("export.invalidUsername") + : hasCodeErrors + ? t("export.invalidCode") + : hasHtmlErrors + ? t("export.invalidHtml") + : hasImageUrlErrors + ? t("export.invalidImages") + : hasMissingImageAlt + ? t("export.missingImageDescription") + : hasUrlErrors + ? t("export.invalidLinks") + : hasLocalCards + ? t("export.localCards") + : t("export.ready"); useEffect(() => { const timer = window.setTimeout(() => { @@ -316,6 +335,7 @@ export function EditorWorkspace({ }, [profile, activeId, compactView]); function editDocument(next: ProfileDocument) { + setRecoverySnapshotProfile(null); setExportFeedback("idle"); setProjectDownloadFeedback(null); setClearFeedback(null); @@ -498,6 +518,7 @@ export function EditorWorkspace({ function recoverWithNewDraft() { if (window.confirm(t("storage.resetConfirmation"))) { + setRecoverySnapshotProfile(null); setClearFeedback(null); setExportFeedback("idle"); setSelectedId(null); @@ -513,6 +534,7 @@ export function EditorWorkspace({ if (isLoading || !window.confirm(t("reset.confirmation"))) return; const cleared = clearReadme(); if (cleared.status === "not_ready") return; + setRecoverySnapshotProfile(null); activityData.reset(); pendingReadmeImportOpen = false; setImportOpen(false); @@ -529,6 +551,7 @@ export function EditorWorkspace({ } function completeImport(next: ProfileDocument) { + setRecoverySnapshotProfile(null); activityData.reset(); setClearFeedback(null); setExportFeedback("idle"); @@ -591,6 +614,7 @@ export function EditorWorkspace({ } function completeProject(next: ProfileDocument) { + setRecoverySnapshotProfile(null); activityData.reset(); setClearFeedback(null); setExportFeedback("idle"); @@ -1098,6 +1122,45 @@ export function EditorWorkspace({ > {exportGuidance}

+
+
+ + setRecoverySnapshotProfile(checked === true ? profile : null) + } + /> +
+ +

+ {t("export.recoverySnapshotHelp")} +

+ {includeRecoverySnapshot && ( +

+ {t("export.recoverySnapshotWarning")} +

+ )} +
+
+
{operationError && ( @@ -694,7 +866,13 @@ export function ReadmeImportPanel({ } >