{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$comment": "No $id here, or in the per-file schemas next to it, ON PURPOSE. A scaffolded cv-content/ pins its `# yaml-language-server: $schema=` header to the release that wrote it (raw.githubusercontent.com/hrtips/cvx/vX.Y.Z/schema/v1/...). With an $id naming the `main` branch, a spec-compliant resolver takes $id as the base URI and resolves the per-file `\"$ref\": \"cvx.schema.json#/$defs/...\"` back to main's copy — so the pin would be cosmetic and a user on an old version would be validated against tomorrow's schema. With no $id, the base URI is the URI the schema was fetched from, which is the pin, and every implementation agrees on that (the spec's default, and what vscode-json-languageservice does regardless). A `main` $id was also a mutable identifier naming a different document every release, and two differently-pinned copies open at once would have collided on it.",
  "title": "CVX content schema (v1)",
  "description": "Canonical definitions for every file CVX reads from cv-content/. Per-file schemas (personal.schema.json, config.schema.json, ...) reference the $defs here — by relative $ref, resolved against wherever this file was fetched from. Content files never break within a schema major version.",
  "$defs": {
    "nonEmptyString": {
      "type": "string",
      "minLength": 1
    },
    "bulletItem": {
      "description": "A bullet line. Either a plain string, or an object when part of the line should be a hyperlink: { text, link: { href, label }, suffix }. Rendered as: text + link.label (clickable) + suffix.",
      "oneOf": [
        {
          "$ref": "#/$defs/nonEmptyString"
        },
        {
          "type": "object",
          "properties": {
            "text": {
              "type": "string",
              "description": "Text before the link."
            },
            "link": {
              "type": "object",
              "properties": {
                "href": {
                  "type": "string",
                  "description": "Destination URL."
                },
                "label": {
                  "type": "string",
                  "description": "Visible, clickable link text."
                }
              },
              "required": [
                "href",
                "label"
              ],
              "additionalProperties": false
            },
            "suffix": {
              "type": "string",
              "description": "Text after the link."
            }
          },
          "required": [
            "text"
          ],
          "additionalProperties": false
        }
      ]
    },
    "linkEntry": {
      "type": "object",
      "description": "A labelled hyperlink used by personal.links.",
      "properties": {
        "label": {
          "$ref": "#/$defs/nonEmptyString",
          "description": "Visible link text, e.g. \"Portfolio\" or \"Blog\". Optional — falls back to the URL if omitted."
        },
        "href": {
          "$ref": "#/$defs/nonEmptyString",
          "description": "Destination URL. Required."
        }
      },
      "required": [
        "href"
      ],
      "additionalProperties": false
    },
    "personal": {
      "title": "personal.yaml",
      "description": "Identity and contact details. Required in practice: 'name' drives the output filename and PDF metadata. (The default two-column layout also needs summary.yaml and experience.yaml present.)",
      "type": "object",
      "properties": {
        "name": {
          "$ref": "#/$defs/nonEmptyString",
          "description": "Full name. Required — also derives the PDF filename (lowercased, spaces to hyphens)."
        },
        "title": {
          "type": "string",
          "description": "Professional headline, e.g. \"Senior Engineer\". Also used for PDF metadata and ATS keyword derivation."
        },
        "company": {
          "type": "string",
          "description": "Current employer, shown under the title."
        },
        "phone": {
          "type": "string",
          "description": "Phone number as displayed."
        },
        "phoneHref": {
          "type": "string",
          "description": "Optional link target for the phone row, e.g. \"tel:+15550100\"."
        },
        "email": {
          "type": "string",
          "description": "Email address; rendered as a mailto: link automatically."
        },
        "linkedin": {
          "type": "string",
          "description": "LinkedIn display text, e.g. \"linkedin.com/in/janedoe\"."
        },
        "linkedinHref": {
          "type": "string",
          "description": "Full LinkedIn URL used as the link target."
        },
        "facebook": {
          "type": "string",
          "description": "Facebook display text. Shown only in the two-column sidebar, not in the ATS header."
        },
        "facebookHref": {
          "type": "string",
          "description": "Full Facebook URL used as the link target."
        },
        "location": {
          "type": "string",
          "description": "City / country line. Plain text, no link."
        },
        "links": {
          "type": "array",
          "description": "Extra labelled links beyond the built-in linkedin/facebook rows — portfolio, blog, GitHub, personal site, etc. Each item is { label, href }.",
          "items": {
            "$ref": "#/$defs/linkEntry"
          }
        }
      },
      "required": [
        "name"
      ],
      "additionalProperties": false
    },
    "summary": {
      "title": "summary.yaml",
      "description": "Professional summary as a list of bullet lines. Items may be plain strings or the object form with an inline hyperlink.",
      "type": "array",
      "items": {
        "$ref": "#/$defs/bulletItem"
      }
    },
    "progressionStep": {
      "type": "object",
      "properties": {
        "title": {
          "$ref": "#/$defs/nonEmptyString",
          "description": "Role title held during this step. Also feeds ATS keyword derivation."
        },
        "period": {
          "type": "string",
          "description": "Free-text date range for this step, e.g. \"2019 – 2021\"."
        }
      },
      "required": [
        "title"
      ],
      "additionalProperties": false
    },
    "experienceEntry": {
      "type": "object",
      "properties": {
        "role": {
          "$ref": "#/$defs/nonEmptyString",
          "description": "Job title. Required. Also feeds ATS keyword derivation."
        },
        "company": {
          "type": "string",
          "description": "Employer name."
        },
        "period": {
          "type": "string",
          "description": "Free-text date range, e.g. \"Mar 2021 – Present\"."
        },
        "location": {
          "type": "string",
          "description": "Where the role was based. Shown in the designed layout only, not in the ATS variant."
        },
        "description": {
          "type": "string",
          "description": "One-paragraph summary of the role."
        },
        "progression": {
          "type": "array",
          "description": "Promotions / role changes within the same company, most recent first.",
          "items": {
            "$ref": "#/$defs/progressionStep"
          }
        },
        "bullets": {
          "type": "array",
          "description": "Achievement bullet points. Items may be plain strings or the object form with an inline hyperlink.",
          "items": {
            "$ref": "#/$defs/bulletItem"
          }
        }
      },
      "required": [
        "role"
      ],
      "additionalProperties": false
    },
    "experience": {
      "title": "experience.yaml",
      "description": "Work history as a list of entries, most recent first.",
      "type": "array",
      "items": {
        "$ref": "#/$defs/experienceEntry"
      }
    },
    "educationEntry": {
      "type": "object",
      "properties": {
        "degree": {
          "$ref": "#/$defs/nonEmptyString",
          "description": "Qualification name, e.g. \"BSc Computer Science\". Required."
        },
        "institution": {
          "$ref": "#/$defs/nonEmptyString",
          "description": "Awarding institution. Required."
        },
        "period": {
          "type": "string",
          "description": "Free-text date range or year. Omit to hide the date line."
        }
      },
      "required": [
        "degree",
        "institution"
      ],
      "additionalProperties": false
    },
    "education": {
      "title": "education.yaml",
      "description": "Education as a list of entries, most recent first.",
      "type": "array",
      "items": {
        "$ref": "#/$defs/educationEntry"
      }
    },
    "certificationEntry": {
      "type": "object",
      "properties": {
        "name": {
          "$ref": "#/$defs/nonEmptyString",
          "description": "Certification name, e.g. \"LFD259: Kubernetes for Developers\". Required. Also feeds ATS keyword derivation."
        },
        "issuer": {
          "type": "string",
          "description": "Awarding body, e.g. \"The Linux Foundation\"."
        },
        "year": {
          "type": "string",
          "description": "Free-text year or date earned. Omit to hide the date line."
        }
      },
      "required": [
        "name"
      ],
      "additionalProperties": false
    },
    "certifications": {
      "title": "certifications.yaml",
      "description": "Professional certifications as a list of entries, most recent first. Kept distinct from achievements.yaml (awards and recognitions).",
      "type": "array",
      "items": {
        "$ref": "#/$defs/certificationEntry"
      }
    },
    "publicationEntry": {
      "type": "object",
      "properties": {
        "title": {
          "$ref": "#/$defs/nonEmptyString",
          "description": "Publication title. Required."
        },
        "venue": {
          "type": "string",
          "description": "Where it appeared — journal, conference, publisher, or site."
        },
        "year": {
          "type": "string",
          "description": "Free-text year or date. Omit to hide the date line."
        }
      },
      "required": [
        "title"
      ],
      "additionalProperties": false
    },
    "publications": {
      "title": "publications.yaml",
      "description": "Publications and written work as a list of entries, most recent first.",
      "type": "array",
      "items": {
        "$ref": "#/$defs/publicationEntry"
      }
    },
    "languageEntry": {
      "type": "object",
      "properties": {
        "language": {
          "$ref": "#/$defs/nonEmptyString",
          "description": "Language name, e.g. \"English\". Required."
        },
        "proficiency": {
          "type": "string",
          "description": "Free-text proficiency, e.g. \"Native\", \"Professional\", \"Conversational\". Omit to show just the language."
        }
      },
      "required": [
        "language"
      ],
      "additionalProperties": false
    },
    "languages": {
      "title": "languages.yaml",
      "description": "Languages spoken as a list of entries.",
      "type": "array",
      "items": {
        "$ref": "#/$defs/languageEntry"
      }
    },
    "competencies": {
      "title": "competencies.yaml",
      "description": "Core skills as a flat list of short strings. Rendered as pills in the designed layout; also feeds ATS keyword derivation.",
      "type": "array",
      "items": {
        "$ref": "#/$defs/nonEmptyString"
      }
    },
    "achievementEntry": {
      "type": "object",
      "properties": {
        "year": {
          "$ref": "#/$defs/nonEmptyString",
          "description": "Bold headline of the achievement — often a year or award name, but any short string works."
        },
        "text": {
          "$ref": "#/$defs/nonEmptyString",
          "description": "Attribution / detail line rendered under the headline."
        }
      },
      "required": [
        "year",
        "text"
      ],
      "additionalProperties": false
    },
    "achievements": {
      "title": "achievements.yaml",
      "description": "Awards and recognitions as a list of entries.",
      "type": "array",
      "items": {
        "$ref": "#/$defs/achievementEntry"
      }
    },
    "refereeEntry": {
      "type": "object",
      "properties": {
        "name": {
          "$ref": "#/$defs/nonEmptyString",
          "description": "Referee's full name. Required."
        },
        "title": {
          "type": "string",
          "description": "Referee's role/title."
        },
        "company": {
          "type": "string",
          "description": "Referee's organisation."
        },
        "email": {
          "type": "string",
          "description": "Contact email."
        },
        "phone": {
          "type": "string",
          "description": "Contact phone."
        }
      },
      "required": [
        "name"
      ],
      "additionalProperties": false
    },
    "referees": {
      "title": "referees.yaml",
      "description": "References as a list of entries. An empty list ([]) prints \"References available upon request.\" in the two-column layout and omits the section in the ATS variant.",
      "type": "array",
      "items": {
        "$ref": "#/$defs/refereeEntry"
      }
    },
    "keywordGroup": {
      "type": "object",
      "description": "Named group of keywords. Group names are for your organisation only — they are flattened and discarded in the PDF metadata.",
      "additionalProperties": {
        "oneOf": [
          {
            "type": "string"
          },
          {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        ]
      }
    },
    "keywords": {
      "title": "keywords.yaml",
      "description": "Optional ATS keywords embedded in PDF metadata (never printed). Accepts a flat list of strings, a map of named groups, a mixed list of strings and group maps, or a single string. Merged with auto-derived keywords (competencies, titles) unless disabled in config.",
      "oneOf": [
        {
          "type": "string"
        },
        {
          "$ref": "#/$defs/keywordGroup"
        },
        {
          "type": "array",
          "items": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "$ref": "#/$defs/keywordGroup"
              }
            ]
          }
        }
      ]
    },
    "config": {
      "title": "config.yaml",
      "description": "Build configuration: theme, layout, first-page pagination, and ATS keyword behaviour.",
      "type": "object",
      "properties": {
        "schemaVersion": {
          "type": "integer",
          "const": 1,
          "description": "Content schema major version. Content files never break within a major."
        },
        "theme": {
          "type": "string",
          "enum": [
            "teal",
            "coral",
            "mono"
          ],
          "default": "teal",
          "description": "Colour theme. One of the built-in themes; an unknown value fails the build."
        },
        "layout": {
          "type": "string",
          "default": "two-column",
          "description": "Layout name, matching a file in cv-content/layouts/ (built-ins: \"two-column\", \"single-column\"). An unknown value warns and falls back to the built-in default."
        },
        "atsKeywords": {
          "type": "object",
          "description": "ATS keyword metadata behaviour.",
          "properties": {
            "enabled": {
              "type": "boolean",
              "default": true,
              "description": "Write the Keywords field into PDF metadata."
            },
            "autoDerive": {
              "type": "boolean",
              "default": true,
              "description": "Merge keywords derived from competencies and role titles."
            },
            "max": {
              "type": "integer",
              "minimum": 1,
              "description": "Cap the number of keywords written. Omit for no cap."
            }
          },
          "additionalProperties": false
        }
      },
      "additionalProperties": false
    },
    "layoutSlot": {
      "description": "One slot in a page region: a section key string (identity-photo, identity-compact, contact, summary, experience, education, competencies, achievements, referees, header-ats, experience:continued, spacer:N), or an object form — { spacer: N } or { <section>: { continued: true } }.",
      "oneOf": [
        {
          "$ref": "#/$defs/nonEmptyString"
        },
        {
          "type": "object",
          "minProperties": 1,
          "maxProperties": 1,
          "properties": {
            "spacer": {
              "type": "number",
              "description": "Vertical spacer height in points."
            }
          },
          "additionalProperties": {
            "type": "object",
            "properties": {
              "continued": {
                "type": "boolean",
                "description": "Render this section's continuation slice."
              }
            },
            "additionalProperties": false
          }
        }
      ]
    },
    "layoutPage": {
      "type": "object",
      "description": "Slots for one page kind. The two columns are read differently: the `main` lists ARE per-page-kind (first.main renders on page 1, last.main on the final page, continuation.main in between), while the `sidebar` lists across first/continuation/last are concatenated into ONE ordered flow that the engine measures and paginates itself — so listing a section under `last` sets its position in the sidebar order, not the page it renders on. identity-photo/identity-compact are the exception: they are injected at the top of every page's sidebar rather than packed.",
      "properties": {
        "sidebar": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/layoutSlot"
          },
          "description": "Slots in the sidebar column (two-column template only). Part of the single ordered sidebar flow — see this object's description: you choose the order, the engine measures the page placement."
        },
        "main": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/layoutSlot"
          },
          "description": "Slots in the main column, for this page kind specifically. Note: the planner measures only the summary and the experience entries here. Any other section renders correctly but is NOT measured, so the page count and overflow figures exclude it and the build reports a main-slot-unmeasured fact."
        }
      },
      "additionalProperties": false
    },
    "layout": {
      "title": "layouts/*.yaml",
      "description": "A layout file: which template to use and which sections appear where on each page kind.",
      "type": "object",
      "properties": {
        "template": {
          "type": "string",
          "enum": [
            "two-column",
            "single-column"
          ],
          "default": "two-column",
          "description": "Page template the layout builds on."
        },
        "pages": {
          "type": "object",
          "properties": {
            "first": {
              "$ref": "#/$defs/layoutPage"
            },
            "continuation": {
              "$ref": "#/$defs/layoutPage"
            },
            "last": {
              "$ref": "#/$defs/layoutPage"
            }
          },
          "additionalProperties": false
        },
        "geometry": {
          "type": "object",
          "description": "Reserved. Currently ignored — page geometry comes from the theme. Kept so existing files with a geometry block stay valid.",
          "additionalProperties": true
        },
        "first": {
          "$ref": "#/$defs/layoutPage",
          "description": "Flat form: equivalent to pages.first when the pages: wrapper is omitted."
        },
        "continuation": {
          "$ref": "#/$defs/layoutPage",
          "description": "Flat form: equivalent to pages.continuation when the pages: wrapper is omitted."
        },
        "last": {
          "$ref": "#/$defs/layoutPage",
          "description": "Flat form: equivalent to pages.last when the pages: wrapper is omitted."
        },
        "spacing": {
          "type": "object",
          "description": "Template-declared vertical spacing, as MULTIPLIERS of the theme's own values (1 = unchanged). Vertical whitespace only: horizontal offsets change wrap widths, hence line counts, hence every measurement, and stay unexposed. Out-of-range values are validation errors, not clamped.",
          "properties": {
            "entryGap": {
              "type": "number",
              "minimum": 0.6,
              "maximum": 1.5,
              "default": 1,
              "description": "Space between experience entries (the entry's bottom margin and the rule's margins). The strongest single lever on page count."
            },
            "bulletGap": {
              "type": "number",
              "minimum": 0.6,
              "maximum": 1.5,
              "default": 1,
              "description": "Space between bullet items, in the summary and within an entry."
            },
            "sectionGap": {
              "type": "number",
              "minimum": 0.6,
              "maximum": 1.5,
              "default": 1,
              "description": "Space around section boundaries and under section titles."
            }
          },
          "additionalProperties": false
        }
      },
      "additionalProperties": false
    }
  }
}
