diff --git a/.eleventy.cjs b/.eleventy.cjs index c5f2b03a..75e1d750 100644 --- a/.eleventy.cjs +++ b/.eleventy.cjs @@ -3,6 +3,8 @@ const path = require('node:path'); // Deployment tooling can be newer than the selected published SDK source. const sourceDirectory = path.resolve(process.env.DOCS_SOURCE_DIRECTORY || __dirname); +const portalDirectory = process.env.DOCS_PORTAL_DIRECTORY || 'docs/portal'; +const templateDirectory = process.env.DOCS_TEMPLATE_DIRECTORY || path.join(sourceDirectory, '.phpdoc/template'); const QUICKSTART_CONTRACT = require(path.join(sourceDirectory, 'docs/quickstart-contract.json')); const QUICKSTART_BASES = QUICKSTART_CONTRACT.reference_resolution?.bases; const QUICKSTART_SOURCES = Object.freeze(Object.fromEntries( @@ -70,14 +72,21 @@ function headingIds(markdown) { module.exports = function configure(eleventyConfig) { eleventyConfig.amendLibrary('md', (markdown) => markdown.use(headingIds)); + // Both rendering engines use the same static product chrome. + for (const [name, file] of [['identityHeader', 'identity-header'], ['identityFooter', 'identity-footer']]) { + eleventyConfig.addShortcode(name, () => fs.readFileSync( + path.join(templateDirectory, `components/${file}.html.twig`), 'utf8', + )); + } + eleventyConfig.addPassthroughCopy({'docs/CNAME': 'CNAME'}); - eleventyConfig.addPassthroughCopy({'docs/portal/assets': 'assets'}); + eleventyConfig.addPassthroughCopy({[path.join(portalDirectory, 'assets')]: 'assets'}); eleventyConfig.addPassthroughCopy({'docs/quickstart-contract.json': 'quickstart-contract.json'}); eleventyConfig.addPassthroughCopy({'docs/quickstart-contract.schema.v2.json': 'quickstart-contract.schema.v2.json'}); eleventyConfig.addPassthroughCopy({'docs/laravel-adoption-contract.json': 'laravel-adoption-contract.json'}); eleventyConfig.addPassthroughCopy({'docs/laravel-adoption-contract.schema.json': 'laravel-adoption-contract.schema.json'}); eleventyConfig.addPassthroughCopy({'.phpdoc/template/analytics': 'analytics'}); - eleventyConfig.addPassthroughCopy({'.phpdoc/template/assets': 'assets'}); + eleventyConfig.addPassthroughCopy({[path.join(templateDirectory, 'assets')]: 'assets'}); eleventyConfig.addPassthroughCopy({'build/api': 'api'}); eleventyConfig.addShortcode('sourceFile', function sourceFile(sourceName, language = 'text', label = sourceName) { @@ -98,7 +107,7 @@ module.exports = function configure(eleventyConfig) { return { dir: { - input: 'docs/portal', + input: portalDirectory, includes: '_includes', data: '_data', output: 'build/site', diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b630d30d..0dfbee9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -558,6 +558,8 @@ jobs: - name: Test documentation release-channel guards if: matrix.source == 'current' run: python "$DOCS_TOOLING_DIRECTORY/scripts/test-docs-release-channel.py" + - name: Test presentation and published SDK source isolation + run: python "$DOCS_TOOLING_DIRECTORY/scripts/prepare-docs-presentation.test.py" - name: Select authenticated dependency trust uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust with: @@ -577,6 +579,7 @@ jobs: - run: npm --prefix "$DOCS_TOOLING_DIRECTORY" audit --audit-level=moderate - run: node "$DOCS_TOOLING_DIRECTORY/scripts/check-docs-examples.mjs" - run: npm --prefix "$DOCS_TOOLING_DIRECTORY" run test:docs-portal-links + - run: npm --prefix "$DOCS_TOOLING_DIRECTORY" run test:docs-identity-status - name: Verify Composer trust before requests uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - run: bash "$DOCS_TOOLING_DIRECTORY/scripts/build-docs.sh" @@ -595,7 +598,15 @@ jobs: - name: Verify missing-resource browser evidence run: npm --prefix "$DOCS_TOOLING_DIRECTORY" run test:docs-browser-failures - name: Check portal and API layouts in Chromium + env: + DOCS_SCREENSHOTS_DIRECTORY: build/one-identity-screenshots run: node "$DOCS_TOOLING_DIRECTORY/scripts/check-docs-browser.mjs" build/site + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + if: always() + with: + name: php-docs-visual-${{ matrix.source }} + path: build/one-identity-screenshots + if-no-files-found: ignore - name: Check repository-owned documentation links offline uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2 with: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 44ccfba3..0c5af333 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,6 +12,7 @@ on: - 'scripts/finalize-api-reference.php' - 'scripts/build-docs.sh' - 'scripts/prepare-docs-tooling.py' + - 'scripts/prepare-docs-presentation.py' - 'scripts/bind-docs-artifact.py' - 'README.md' - 'composer.json' @@ -24,6 +25,14 @@ on: description: 'Published stable SDK tag to deploy, or empty for current main' type: string required: false + build_only: + description: 'Build and qualify the exact stable tuple without deploying Pages' + type: boolean + default: false + presentation_source: + description: 'Optional exact accepted-main presentation commit; published SDK source stays unchanged' + type: string + required: false stable_source: description: 'Optional exact main commit with docs fixes and identical published SDK code' type: string @@ -49,6 +58,7 @@ jobs: env: STABLE_TAG: ${{ inputs.stable_tag }} STABLE_SOURCE: ${{ inputs.stable_source }} + PRESENTATION_SOURCE: ${{ inputs.presentation_source }} run: | if [[ ! "$STABLE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo 'stable_tag must name an exact stable SDK version' >&2 @@ -58,8 +68,12 @@ jobs: echo 'stable_source must be an exact lowercase source commit' >&2 exit 1 fi + if [ -n "$PRESENTATION_SOURCE" ] && [[ ! "$PRESENTATION_SOURCE" =~ ^[0-9a-f]{40}$ ]]; then + echo 'presentation_source must be an exact lowercase source commit' >&2 + exit 1 + fi - name: Require a stable tag for a documentation source override - if: inputs.stable_source != '' && inputs.stable_tag == '' + if: (inputs.stable_source != '' || inputs.presentation_source != '') && inputs.stable_tag == '' run: exit 1 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: @@ -96,6 +110,11 @@ jobs: STABLE_SOURCE: ${{ inputs.stable_source }} GH_TOKEN: ${{ github.token }} run: python "$DOCS_TOOLING_DIRECTORY/scripts/check-docs-release-channel.py" + - name: Stage presentation over the unchanged published SDK docs + if: inputs.presentation_source != '' + env: + PRESENTATION_SOURCE: ${{ inputs.presentation_source }} + run: python "$DOCS_TOOLING_DIRECTORY/scripts/prepare-docs-presentation.py" "$PRESENTATION_SOURCE" "$RUNNER_TEMP/docs-presentation" - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: '24' @@ -106,6 +125,7 @@ jobs: - run: npm --prefix "$DOCS_TOOLING_DIRECTORY" audit --audit-level=moderate - run: node "$DOCS_TOOLING_DIRECTORY/scripts/check-docs-examples.mjs" - run: npm --prefix "$DOCS_TOOLING_DIRECTORY" run test:docs-portal-links + - run: npm --prefix "$DOCS_TOOLING_DIRECTORY" run test:docs-identity-status - name: Check repository-owned documentation links offline uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2 with: @@ -140,10 +160,19 @@ jobs: - name: Test missing-resource browser evidence run: npm --prefix "$DOCS_TOOLING_DIRECTORY" run test:docs-browser-failures - name: Check portal and API layouts in Chromium + env: + DOCS_SCREENSHOTS_DIRECTORY: build/one-identity-screenshots run: node "$DOCS_TOOLING_DIRECTORY/scripts/check-docs-browser.mjs" build/site + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + if: always() + with: + name: php-docs-visual-checks + path: build/one-identity-screenshots + if-no-files-found: ignore - name: Bind Pages artifact to the exact source revision env: EXPECTED_SOURCE_REVISION: ${{ steps.release-channel.outputs.source_revision }} + STABLE_TAG: ${{ inputs.stable_tag }} run: | test "$(git -C "$DOCS_TOOLING_DIRECTORY" rev-parse HEAD)" = "$GITHUB_WORKFLOW_SHA" python "$DOCS_TOOLING_DIRECTORY/scripts/bind-docs-artifact.py" "$EXPECTED_SOURCE_REVISION" @@ -156,6 +185,7 @@ jobs: if: >- github.server_url == 'https://github.com' && github.ref == 'refs/heads/main' && + inputs.build_only != true && needs.build.outputs.stable == 'true' needs: build runs-on: ubuntu-latest diff --git a/.phpdoc/template/assets/api-reference.css b/.phpdoc/template/assets/api-reference.css index ffed3179..6ea0aa63 100644 --- a/.phpdoc/template/assets/api-reference.css +++ b/.phpdoc/template/assets/api-reference.css @@ -157,7 +157,7 @@ padding: clamp(1.5rem, 4vw, 3.5rem); margin-bottom: var(--spacing-lg); color: #eef8ff; - background: linear-gradient(135deg, #102a43 0%, #0b6477 100%); + background: var(--surface); border-radius: .75rem; } @@ -389,216 +389,6 @@ } } -/* Keep the generated reference visually subordinate to the authored portal. */ -:root { - color-scheme: dark; - --primary-color: #9effc7; - --primary-color-darken: #6fe3a1; - --primary-color-darker: #9effc7; - --primary-color-darkest: #f7f7f2; - --primary-color-lighten: #263746; - --primary-color-lighter: #101820; - --dark-gray: #263746; - --light-gray: #162330; - --text-color: #c6d0da; - --header-bg-color: #0b1118; - --code-background-color: #101820; - --code-border-color: #263746; - --button-border-color: #263746; - --button-color-primary: #9effc7; - --button-text-color: #c6d0da; - --button-text-color-primary: #082016; - --popover-background-color: rgb(16 24 32 / 96%); - --link-color-primary: #9effc7; - --link-hover-color-primary: #c5ffdc; - --form-field-border-color: #263746; - --form-field-color: #101820; - --table-separator-color: #263746; - --title-text-color: #f7f7f2; - --sidebar-border-color: #263746; - --font-primary: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-secondary: var(--font-primary); - --font-monospace: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - --letter-spacing--primary: 0; -} - -body { - background: #0b1118; - color: #c6d0da; -} - -.phpdocumentor-header { - gap: 1rem; - max-width: none; - padding-inline: max(1rem, calc((100vw - 1160px) / 2)); - background: rgb(11 17 24 / 96%); - border-bottom: 1px solid #263746; -} - -.phpdocumentor-title { - color: #f7f7f2; - font-size: 1rem; - font-weight: 720; -} - -.phpdocumentor-title__link, -.phpdocumentor-title__link:hover { - color: #f7f7f2; - transform: none; -} - -.phpdocumentor-search { - min-width: 0; -} - -.phpdocumentor-search__field { - color: #f7f7f2; - background: #101820; - border-color: #263746; -} - -.phpdocumentor-search__field::placeholder, -.phpdocumentor-search__icon { - color: #adbac8; -} - -.dw-api-guide-link { - color: #f7f7f2; - background: #101820; - border-color: #263746; -} - -.dw-api-guide-link:hover, -.dw-api-guide-link:focus-visible { - color: #9effc7; - border-color: #9effc7; -} - -.dw-cloud-promotion { - max-width: 1160px; - margin: 1rem auto; - padding: .85rem 1rem; - color: #adbac8; - background: #101820; - border: 1px solid #263746; - border-left: 3px solid #8da2ff; - border-radius: 8px; -} - -.dw-cloud-promotion h2 { - color: #f7f7f2; - font-size: 1rem; -} - -.dw-cloud-promotion__eyebrow { - color: #8da2ff; -} - -.dw-cloud-promotion__action { - color: #c9d3ff; - border-color: #55679f; - border-radius: 6px; -} - -.dw-cloud-promotion__action:hover, -.dw-cloud-promotion__action:focus-visible { - color: #0b1118; - background: #8da2ff; - border-color: #8da2ff; -} - -.phpdocumentor { - padding-top: 1rem; -} - -.phpdocumentor .phpdocumentor-sidebar { - color: #adbac8; -} - -.phpdocumentor .phpdocumentor-sidebar abbr, -.phpdocumentor .phpdocumentor-sidebar a { - color: #adbac8; -} - -.phpdocumentor .phpdocumentor-sidebar a:hover, -.phpdocumentor .phpdocumentor-sidebar a.-active, -.phpdocumentor .phpdocumentor-sidebar__category-header { - color: #9effc7; -} - -.phpdocumentor-sidebar__menu-icon { - padding: .7rem .85rem; - color: #9effc7; - background: #101820; - border: 1px solid #263746; - border-radius: 6px; - letter-spacing: 0; - text-align: left; -} - -.php-quickstart__hero { - color: #c6d0da; - background: linear-gradient(145deg, #162330, #101820); - border: 1px solid #263746; -} - -.php-quickstart__hero h2 { - color: #f7f7f2; -} - -.php-quickstart__eyebrow { - color: #9effc7; -} - -.php-quickstart__path-nav a { - color: #f7f7f2; - border-color: #3b5063; -} - -.php-quickstart__path-nav a:hover, -.php-quickstart__path-nav a:focus-visible, -.php-quickstart__path-nav a[aria-current="page"] { - color: #082016; - background: #9effc7; - border-color: #9effc7; -} - -.php-quickstart__section { - color: #c6d0da; - background: #101820; - border-color: #263746; - box-shadow: none; -} - -.php-quickstart__section h1, -.php-quickstart__section h2, -.php-quickstart__section h3, -.php-quickstart__section h4, -.php-quickstart__section h5, -.php-quickstart__section h6 { - color: #f7f7f2; -} - -.phpdocumentor-signature, -.phpdocumentor-element .phpdocumentor-element, -.phpdocumentor-table, -.phpdocumentor-table th, -.phpdocumentor-table td { - border-color: #263746 !important; -} - -.phpdocumentor-search-results__dialog, -.phpdocumentor-search-results__dialog > .phpdocumentor-search, -.phpdocumentor-search-results__body { - color: #c6d0da; - background: #101820; -} - -.phpdocumentor-back-to-top { - color: #9effc7; - background: #162330; -} - @media (min-width: 900px) { .phpdocumentor .phpdocumentor-sidebar.-three { width: 210px; @@ -656,3 +446,54 @@ body { padding-top: .25rem; } } + +/* One Identity: shared type and palette, preserving generated reference layout. */ +:root { + --font-primary: var(--body); --font-secondary: var(--display); --font-monospace: var(--mono); + --primary-color: var(--action); --primary-color-darken: var(--action); --primary-color-lighten: var(--line); --primary-color-darker: var(--action); --primary-color-darkest: var(--ink); --primary-color-lighter: var(--surface); + --dark-gray: var(--line); --light-gray: var(--paper); --header-bg-color: var(--surface); + --code-border-color: var(--line); --button-border-color: var(--line); + --button-color-primary: var(--action); --button-text-color: var(--ink2); --button-text-color-primary: var(--action-ink); + --popover-background-color: var(--surface); --link-color-primary: var(--action); --link-hover-color-primary: var(--action); + --form-field-border-color: var(--line); --form-field-color: var(--surface); --title-text-color: var(--ink); --sidebar-border-color: var(--line); --letter-spacing--primary: 0; + --text-color: var(--ink2); --text-color-muted: var(--muted); --text-color-lighten: var(--muted); + --header-background-color: var(--surface); --sidebar-background-color: var(--paper); + --code-background-color: var(--code-bg); --code-color: var(--code-ink); + --table-separator-color: var(--line); --border-color: var(--line); +} +body { background: var(--paper); color: var(--ink); font-family: var(--body); } +.phpdocumentor-header { background: var(--surface); border-bottom: 1px solid var(--line); color: var(--ink); } +.phpdocumentor-header .phpdocumentor-title__link { color: var(--ink); font: 600 20px/1.3 var(--display); } +.phpdocumentor .phpdocumentor-content, .phpdocumentor .phpdocumentor-content p, +.phpdocumentor .phpdocumentor-description, .phpdocumentor-table { color: var(--ink2); } +.phpdocumentor :is(h1,h2,h3,h4,h5,h6) { color: var(--ink); font-family: var(--display); font-weight: 600; } +.phpdocumentor a, .phpdocumentor .phpdocumentor-sidebar a:hover, +.phpdocumentor .phpdocumentor-sidebar a.-active, +.phpdocumentor .phpdocumentor-sidebar__category-header { color: var(--action); } +.phpdocumentor .phpdocumentor-sidebar a { color: var(--ink2); } +.phpdocumentor .phpdocumentor-sidebar { background: var(--paper); border-color: var(--line); } +.phpdocumentor-search__field { color: var(--ink); background: var(--paper); border: 1px solid var(--line); border-radius: 6px; } +.phpdocumentor-sidebar__menu-icon, .dw-api-guide-link { color: var(--action); background: var(--surface); border: 1px solid var(--line); font-family: var(--body); min-height: 44px; } +.phpdocumentor .phpdocumentor-signature, .phpdocumentor-element .phpdocumentor-element, +.phpdocumentor-table, .phpdocumentor-table th, .phpdocumentor-table td { border-color: var(--line); } +.phpdocumentor .phpdocumentor-signature, .phpdocumentor pre, .phpdocumentor code[class*='language-'] { background: var(--code-bg); color: var(--code-ink); font-family: var(--mono); text-shadow: none; } +.phpdocumentor .token { color: var(--code-ink); background: transparent; } +.phpdocumentor .token.comment { color: #9AA6A1; } +.phpdocumentor .token.keyword, .phpdocumentor .token.function { color: #5FE0A6; } +.phpdocumentor .phpdocumentor-signature { max-width: 100%; overflow-x: auto; border-radius: 8px; } +.phpdocumentor .phpdocumentor-signature a { color: #5FE0A6; } +.phpdocumentor-search-results__dialog, .phpdocumentor-search-results__dialog > .phpdocumentor-search, +.phpdocumentor-search-results__body { color: var(--ink2); background: var(--surface); } +.phpdocumentor-back-to-top { color: var(--action); background: var(--surface); } +.php-quickstart__hero, .php-quickstart__section, .dw-cloud-promotion { background: var(--surface); color: var(--ink2); border: 1px solid var(--line); border-radius: 8px; box-shadow: none; } +.php-quickstart__hero h2, .php-quickstart__section :is(h1,h2,h3,h4,h5,h6) { color: var(--ink); } +.php-quickstart__hero h2 { font-size: clamp(32px, 4vw, 48px); letter-spacing: -.03em; } +.php-quickstart__eyebrow, .dw-cloud-promotion__eyebrow { color: var(--action); font: 400 12px var(--mono); letter-spacing: .08em; } +.php-quickstart__path-nav a { color: var(--action); border-color: var(--line); border-radius: 6px; min-height: 44px; } +.php-quickstart__path-nav a:hover, .php-quickstart__path-nav a:focus-visible { color: var(--action-ink); background: var(--action); border-color: var(--action); } +.dw-cloud-promotion__action { border-radius: 6px; min-height: 44px; color: var(--action); border-color: var(--action); } +.dw-cloud-promotion__action:hover, .dw-cloud-promotion__action:focus-visible { background: var(--action); color: var(--action-ink); } +.phpdocumentor-header .dw-api-guide-link { color: var(--action); } +body:has(.phpdocumentor-search-results:not(.phpdocumentor-search-results--hidden)) > .site-header, +body:has(.phpdocumentor-search-results:not(.phpdocumentor-search-results--hidden)) > .site-footer { visibility: hidden; } +.phpdocumentor-sidebar__menu-icon { letter-spacing: normal; text-transform: none; } diff --git a/.phpdoc/template/components/identity-footer.html.twig b/.phpdoc/template/components/identity-footer.html.twig new file mode 100644 index 00000000..6b1c8517 --- /dev/null +++ b/.phpdoc/template/components/identity-footer.html.twig @@ -0,0 +1,12 @@ + diff --git a/.phpdoc/template/components/identity-header.html.twig b/.phpdoc/template/components/identity-header.html.twig new file mode 100644 index 00000000..13be5a1c --- /dev/null +++ b/.phpdoc/template/components/identity-header.html.twig @@ -0,0 +1,26 @@ + diff --git a/.phpdoc/template/layout.html.twig b/.phpdoc/template/layout.html.twig index 8c8b64c4..c77e2bb3 100644 --- a/.phpdoc/template/layout.html.twig +++ b/.phpdoc/template/layout.html.twig @@ -1,15 +1,74 @@ -{% extends 'base::layout.html.twig' %} - -{% block stylesheets %} - - - - - -{% endblock %} - -{% block javascripts %} - {{ parent() }} - - -{% endblock %} + + + + + {% block head %} + {% block title %}{{ project.name }}{% endblock %} + {% endblock %} + + + {{ renderBaseUrlHeader() }} + + + + + + + + {% block stylesheets %} + + + + + + + {% endblock %} + {% block javascripts %} + + + + + + + + + + {% endblock %} + + + + {% include 'components/identity-header.html.twig' %} + {% include 'components/header.html.twig' %} + +
+
+ {% include 'components/sidebar.html.twig' %} + +
+ {% if block('on_this_page') is defined %} +
+ {% endif %} + {% block content %}{% endblock %} + {% if block('on_this_page') is defined %} +
+
+ {{ block('on_this_page') }} +
+ {% endif %} +
+ {% include 'components/search-results.html.twig' %} +
+ {{ include('components/back-to-top.html.twig') }} +
+ + {% include 'components/identity-footer.html.twig' %} + + + + + + + + diff --git a/docs/PORTAL.md b/docs/PORTAL.md new file mode 100644 index 00000000..2b1c33b0 --- /dev/null +++ b/docs/PORTAL.md @@ -0,0 +1,48 @@ +# PHP documentation presentation and published source + +The One Identity presentation lives in the existing Eleventy portal and +phpDocumentor templates. Both rendering engines share the static identity +header/footer. The canonical flowchart SVG is unchanged; typography is +self-hosted with its font licenses. Light is the default; dark is an explicit +user preference. The linked status pill uses the unmodified shared Docs #484 reducer/watch module, bounded aggregate labels and an honest neutral fallback. Cloud owns live public-GET CORS acceptance; source fixture results do not assert service health. + +## Build and qualify + +Use the repository's normal documentation commands and CI. The browser check +covers the home, framework guides and generated API at desktop, tablet, +mobile and short-height sizes. New presentation pages exercise both themes, +keyboard SDK switching, default theme behavior and exact clipboard copying. +Historical published pages retain their legacy compatibility checks. +`DOCS_SCREENSHOTS_DIRECTORY` saves representative home/guide/API screenshots. + +## Stable publication inputs + +The existing protected **PHP developer portal** workflow still selects +`stable_tag` and optional `stable_source` using the unchanged published-product +comparison. The presentation does not change that SDK source, its PHP/framework +support requirements, examples, contracts or API implementation. + +Use `build_only: true` to render and qualify the exact stable tuple before a separately authorized deployment through the existing Pages environment. It does not bypass channel or presentation guards. Existing dispatch behavior is preserved when the flag is false. + +The optional `presentation_source` names an exact accepted-main commit. It +requires a published stable tag. `prepare-docs-presentation.py` stages only the +landing page, layout, static assets, generated-reference layout and identity +components over copies of the selected source's portal/templates. Other guide +bodies and the API index remain selected-source inputs. The workflow-owned +release-data adapter reads the selected SDK's manifest and quickstart contract. +It never reads release facts from the presentation commit. + +Source code, Composer manifests, quickstart/adoption contracts, examples and +runtime configuration cannot be overlaid. Generated API symbols still come +from the selected published SDK's `src`. The selected Git checkout remains +unchanged. Mutable/unaccepted presentation references, symlinks, incomplete +presentations and changed staged files refuse. Seven offline Git-history controls +exercise that separation and final artifact binding. + +The deployment audit keeps `source_revision` and also records the SDK and +presentation revisions, plus the published tag commit and exact workflow revision for a stable dispatch. Record the published tag, selected stable source, +presentation commit, exact workflow revision, required-check evidence and +artifact digest before deployment; verify the resulting public audit and +actual rendered pages afterward. Source CI and previews do not authorize a +tag, package release, future API/support claim or a held publishing path. +The combined-minor gates remain separate from this documentation redesign. diff --git a/docs/portal/_data/release.js b/docs/portal/_data/release.js index c819aa76..ea46b6dc 100644 --- a/docs/portal/_data/release.js +++ b/docs/portal/_data/release.js @@ -1,5 +1,7 @@ -const composer = require('../../../composer.json'); -const quickstart = require('../../quickstart-contract.json'); +const path = require('node:path'); +const source = process.env.DOCS_SOURCE_DIRECTORY || path.resolve(__dirname, '../../..'); +const composer = require(path.join(source, 'composer.json')); +const quickstart = require(path.join(source, 'docs/quickstart-contract.json')); const release = composer.extra['durable-workflow']; const sdkVersion = release['product-train']; diff --git a/docs/portal/_includes/layout.njk b/docs/portal/_includes/layout.njk index 9a92a02d..5f32324b 100644 --- a/docs/portal/_includes/layout.njk +++ b/docs/portal/_includes/layout.njk @@ -6,9 +6,11 @@ {% if title %}{{ title }} · {% endif %}Durable Workflow PHP SDK - + + + @@ -24,29 +26,12 @@ + - + {% identityHeader %} {% if layoutType == 'home' %}
{{ content | safe }}
@@ -82,14 +67,6 @@ {% endif %} - + {% identityFooter %} diff --git a/docs/portal/assets/brand.svg b/docs/portal/assets/brand.svg new file mode 100644 index 00000000..02879965 --- /dev/null +++ b/docs/portal/assets/brand.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/docs/portal/assets/favicon.svg b/docs/portal/assets/favicon.svg index c1432ab6..02879965 100644 --- a/docs/portal/assets/favicon.svg +++ b/docs/portal/assets/favicon.svg @@ -1,5 +1,9 @@ - - - - - + + + + + + + + + \ No newline at end of file diff --git a/docs/portal/assets/fonts/README.txt b/docs/portal/assets/fonts/README.txt new file mode 100644 index 00000000..5990db45 --- /dev/null +++ b/docs/portal/assets/fonts/README.txt @@ -0,0 +1 @@ +Shared self-hosted Inter400/500/600, JetBrainsMono400/500/600 and SpaceGrotesk600 WOFF2 bytes match durable-workflow/durable-workflow.github.io commit 222d13ef1f04afdaa6784687c94023177adb3b81, static/identity/fonts. The adjacent original font manifest and OFL licenses retain sources and hashes. Local fonts.css only adjusts the relative URL mount. diff --git a/docs/portal/assets/fonts/fonts.css b/docs/portal/assets/fonts/fonts.css new file mode 100644 index 00000000..dd577cac --- /dev/null +++ b/docs/portal/assets/fonts/fonts.css @@ -0,0 +1,25 @@ +/* Self-hosted OFL fonts. Sources and hashes: fonts/manifest.json. */ +@font-face { + font-family: 'Inter'; + src: url('./inter-latin.woff2') format('woff2'); + font-style: normal; + font-weight: 400 600; + font-display: swap; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'JetBrains Mono'; + src: url('./jetbrains-mono-latin.woff2') format('woff2'); + font-style: normal; + font-weight: 400 600; + font-display: swap; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Space Grotesk'; + src: url('./space-grotesk-latin.woff2') format('woff2'); + font-style: normal; + font-weight: 600; + font-display: swap; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/docs/portal/assets/fonts/inter-OFL.txt b/docs/portal/assets/fonts/inter-OFL.txt new file mode 100644 index 00000000..21f6aff9 --- /dev/null +++ b/docs/portal/assets/fonts/inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/portal/assets/fonts/inter-latin.woff2 b/docs/portal/assets/fonts/inter-latin.woff2 new file mode 100644 index 00000000..d15208de Binary files /dev/null and b/docs/portal/assets/fonts/inter-latin.woff2 differ diff --git a/docs/portal/assets/fonts/jetbrains-mono-latin.woff2 b/docs/portal/assets/fonts/jetbrains-mono-latin.woff2 new file mode 100644 index 00000000..4d09cda4 Binary files /dev/null and b/docs/portal/assets/fonts/jetbrains-mono-latin.woff2 differ diff --git a/docs/portal/assets/fonts/jetbrainsmono-OFL.txt b/docs/portal/assets/fonts/jetbrainsmono-OFL.txt new file mode 100644 index 00000000..821a3dac --- /dev/null +++ b/docs/portal/assets/fonts/jetbrainsmono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/portal/assets/fonts/manifest.json b/docs/portal/assets/fonts/manifest.json new file mode 100644 index 00000000..7eff1ef3 --- /dev/null +++ b/docs/portal/assets/fonts/manifest.json @@ -0,0 +1,38 @@ +{ + "css_source": "https://fonts.googleapis.com/css2?family=Inter:wght@400..600&family=JetBrains+Mono:wght@400..600&family=Space+Grotesk:wght@600&display=swap", + "fonts": [ + { + "bytes": 48256, + "family": "Inter", + "file": "inter-latin.woff2", + "license_file": "inter-OFL.txt", + "license_sha256": "5b9321a4298cfeb6b34354164a1c3afc3db114569984c502b9b35d988fd58c57", + "license_source": "https://raw.githubusercontent.com/google/fonts/24ddc1f324cac5d9f5aedca126d298f56e3f1640/ofl/inter/OFL.txt", + "sha256": "3100e775e8616cd2611beecfa23a4263d7037586789b43f035236a2e6fbd4c62", + "source": "https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2" + }, + { + "bytes": 31432, + "family": "JetBrains Mono", + "file": "jetbrains-mono-latin.woff2", + "license_file": "jetbrainsmono-OFL.txt", + "license_sha256": "b2fe5e8987594e9ffd1d2ca52a2f5d73eb8335243893c5d6254b5ad69269591d", + "license_source": "https://raw.githubusercontent.com/google/fonts/24ddc1f324cac5d9f5aedca126d298f56e3f1640/ofl/jetbrainsmono/OFL.txt", + "sha256": "83c005d49d8a6a50474c73a5a36ac0468076e9c4a29da7bdb14995d80560a5be", + "source": "https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwg.woff2" + }, + { + "bytes": 13284, + "family": "Space Grotesk", + "file": "space-grotesk-latin.woff2", + "license_file": "spacegrotesk-OFL.txt", + "license_sha256": "564ce565c371c5e5bbf286006565a7c9aa55a9f56e7ca58d56e05d649dd61a72", + "license_source": "https://raw.githubusercontent.com/google/fonts/24ddc1f324cac5d9f5aedca126d298f56e3f1640/ofl/spacegrotesk/OFL.txt", + "sha256": "685bbbf69fa616df1ef81847c85fc76be097ddfb3468ff2257be54511ab3130f", + "source": "https://fonts.gstatic.com/s/spacegrotesk/v22/V8mQoQDjQSkFtoMM3T6r8E7mF71Q-gOoraIAEj42VnskPMA.woff2" + } + ], + "license_repository_commit": "24ddc1f324cac5d9f5aedca126d298f56e3f1640", + "runtime_cdn": false, + "self_hosted": true +} diff --git a/docs/portal/assets/fonts/space-grotesk-latin.woff2 b/docs/portal/assets/fonts/space-grotesk-latin.woff2 new file mode 100644 index 00000000..a1db41a0 Binary files /dev/null and b/docs/portal/assets/fonts/space-grotesk-latin.woff2 differ diff --git a/docs/portal/assets/fonts/spacegrotesk-OFL.txt b/docs/portal/assets/fonts/spacegrotesk-OFL.txt new file mode 100644 index 00000000..cb512b9a --- /dev/null +++ b/docs/portal/assets/fonts/spacegrotesk-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/portal/assets/identity.css b/docs/portal/assets/identity.css new file mode 100644 index 00000000..fbe9edd6 --- /dev/null +++ b/docs/portal/assets/identity.css @@ -0,0 +1,99 @@ +@import url('/assets/fonts/fonts.css'); +:root { + color-scheme: light; + --paper: #F6F7F5; --surface: #FFFFFF; --ink: #0F1513; --ink2: #3B4541; + --muted: #5E6964; --line: #DDE2DF; --action: #0F7A52; --tint: #E3F4EC; + --action-ink: #FFFFFF; --code-ink: #E8EDEA; --code-bg: #0F1513; + --display: 'Space Grotesk', sans-serif; --body: Inter, system-ui, sans-serif; + --mono: 'JetBrains Mono', monospace; + font-family: var(--body); font-size: 16px; +} +:root[data-theme='dark'] { + color-scheme: dark; + --paper: #0F1513; --surface: #172019; --ink: #E8EDEA; --ink2: #B7C2BD; + --muted: #9AA6A1; --line: #2E3934; --action: #5FE0A6; --tint: #123B2A; + --action-ink: #0F1513; +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--body); line-height: 1.5; min-width: 0; } +a { color: var(--action); text-underline-offset: .18em; } +a:hover { text-decoration-thickness: 2px; } +button, input, summary { font: inherit; } +button, summary { cursor: pointer; } +img { max-width: 100%; } +:focus-visible { outline: 3px solid var(--action); outline-offset: 4px; } +h1, h2, h3, h4 { color: var(--ink); font-family: var(--display); font-weight: 600; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; clip-path: inset(50%); overflow: hidden; white-space: nowrap; } +.skip-link { position: absolute; top: -100px; left: 16px; padding: 12px 20px; background: var(--surface); color: var(--action); z-index: 200; } +.skip-link:focus { top: 8px; } +.site-header { position: relative; z-index: 30; background: var(--surface); border-bottom: 1px solid var(--line); } +.header-inner { max-width: 1200px; margin: auto; padding: 12px 24px; min-height: 76px; display: flex; align-items: center; gap: 32px; } +.brand { display: inline-flex; flex-shrink: 0; align-items: center; gap: 10px; color: var(--ink); text-decoration: none; white-space: nowrap; } +.brand-mark { display: block; flex-shrink: 0; object-fit: contain; } +[data-theme='dark'] .brand-mark { background: #F6F7F5; border-radius: 8px; padding: 4px; width: 38px; height: 38px; } +.brand-name { font: 600 17px var(--display); } +.brand-divider { color: var(--muted); } +.brand-product { font: 500 13px var(--mono); color: var(--action); } +.primary-nav { display: flex; align-items: center; gap: 24px; flex: 1; font-size: 14px; } +.primary-nav > a:not(.button), .sdk-switcher summary { color: var(--ink2); text-decoration: none; } +.primary-nav > a:hover, .sdk-switcher summary:hover { color: var(--action); } +.header-github { margin-left: auto; } +.button { border-radius: 6px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 20px; border: 1px solid var(--line); text-decoration: none; font-size: 15px; font-weight: 600; } +.button.primary { background: var(--action); color: var(--action-ink); border-color: var(--action); } +.button.secondary { background: var(--surface); color: var(--ink); } +.button:hover { filter: brightness(.96); } +.primary-nav .header-cta { font-size: 14px; padding-inline: 16px; white-space: nowrap; } +.theme-toggle { border: 1px solid var(--line); border-radius: 6px; min-height: 44px; padding: 8px 10px; color: var(--ink2); background: var(--surface); font-size: 13px; } +.sdk-switcher { position: relative; } +.sdk-switcher summary { min-height: 44px; display: flex; align-items: center; gap: 5px; list-style: none; } +.sdk-switcher summary::-webkit-details-marker { display: none; } +.sdk-switcher summary::after { content: '⌄'; } +.sdk-menu { position: absolute; top: 100%; left: -12px; min-width: 160px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 8px; z-index: 40; } +.sdk-menu a { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; min-height: 44px; color: var(--ink2); text-decoration: none; border-radius: 6px; } +.sdk-menu a:hover, .sdk-menu a[aria-current] { background: var(--tint); color: var(--action); } +.nav-toggle { display: none; color: var(--ink); border: 1px solid var(--line); background: var(--surface); border-radius: 6px; min-height: 44px; padding: 8px 12px; } +.site-footer { background: var(--surface); border-top: 1px solid var(--line); margin-top: 0; } +.footer-grid { max-width: 1200px; margin: auto; padding: 48px 24px 24px; display: grid; grid-template-columns: 1.45fr 1fr 1fr 1fr; gap: 32px; font-size: 14px; } +.footer-brand .brand-name { font-size: 16px; } +.footer-brand p { color: var(--muted); max-width: 260px; line-height: 1.55; } +.footer-grid nav { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; } +.footer-grid h2 { font: 400 12px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin: 0 0 2px; } +.footer-grid nav a { color: var(--ink2); text-decoration: none; } +.footer-grid nav a:hover { color: var(--action); text-decoration: underline; } +.footer-bottom { max-width: 1152px; margin: auto; padding: 20px 0 32px; border-top: 1px solid var(--line); display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; font-size: 13px; color: var(--muted); } +.status-pill { display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 999px; color: var(--muted); border: 1px solid var(--line); text-decoration: none; font: 400 12px var(--mono); } +.status-pill > span { width: 8px; height: 8px; border: 1px solid var(--muted); border-radius: 50%; } +.code-stage { background: var(--code-bg); border: 1px solid #2E3934; border-radius: 8px; margin: 20px 0; max-width: 100%; min-width: 0; overflow: hidden; color: var(--code-ink); } +.code-stage__bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 16px; border-bottom: 1px solid #2E3934; font: 400 12px var(--mono); color: #9AA6A1; } +.copy-button { font: 400 12px var(--mono); color: #B7C2BD; background: transparent; border: 1px solid #5E6964; border-radius: 4px; min-height: 32px; padding: 4px 8px; } +.code-stage pre { margin: 0; padding: 18px 16px; max-width: 100%; overflow-x: auto; } +.code-stage code, .code-stage pre { font: 400 13px/1.85 var(--mono); color: var(--code-ink); white-space: pre; } +@media (max-width: 1100px) { .header-inner, .primary-nav { gap: 16px; } .footer-bottom { margin-inline: 24px; } } +@media (max-width: 920px) { + .header-inner { justify-content: space-between; flex-wrap: wrap; } + .nav-toggle { display: inline-flex; align-items: center; } + .primary-nav { display: none; flex-basis: 100%; flex-wrap: wrap; gap: 8px 24px; } + .primary-nav.is-open { display: flex; } + .primary-nav > a:not(.button) { min-height: 44px; display: inline-flex; align-items: center; } + .header-github { margin-left: 0; } +} +@media (max-width: 600px) { + .header-inner { padding: 12px 18px; gap: 12px; } + .brand { gap: 7px; } .brand-name { font-size: 15px; } + .brand-product { font-size: 12px; } + .footer-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 40px 18px 24px; gap: 28px; } + .footer-brand { grid-column: 1 / -1; } + .footer-bottom { margin-inline: 18px; } + .copy-button { min-height: 44px; } +} +.sdk-switcher:not([open]) .sdk-menu { display: none; } +.status-pill[data-state='operational'] { color: var(--action); background: var(--tint); border-color: var(--tint); } +.status-pill[data-state='operational'] > span:first-child { background: var(--action); border-color: var(--action); } +.status-pill[data-state='degraded'] { color: #8A5800; background: #FFF4D6; border-color: #D6B86A; } +.status-pill[data-state='incident'] { color: #A42124; background: #FDEDEE; border-color: #E5B5B7; } +.status-pill[data-state='maintenance'] { color: #225EAA; background: #EAF2FC; border-color: #A9C5E9; } +[data-theme='dark'] .status-pill[data-state='degraded'] { color: #F2C46D; background: #302714; border-color: #6F5728; } +[data-theme='dark'] .status-pill[data-state='incident'] { color: #F5A2A5; background: #351C20; border-color: #71373E; } +[data-theme='dark'] .status-pill[data-state='maintenance'] { color: #A4C9F6; background: #192B40; border-color: #355C88; } +.status-pill .status-label { width: auto; height: auto; border: 0; border-radius: 0; } +.sdk-switcher summary, .sdk-menu a[aria-current], .footer-grid a[aria-current] { color: var(--ink); font-weight: 600; } diff --git a/docs/portal/assets/identity/README.txt b/docs/portal/assets/identity/README.txt new file mode 100644 index 00000000..6b583117 --- /dev/null +++ b/docs/portal/assets/identity/README.txt @@ -0,0 +1 @@ +Canonical shared public status module, unmodified from durable-workflow/durable-workflow.github.io commit f8198fa25998eac2aeb537f3b1655bfe59c841a1, static/identity/status.mjs. Shared reducer/watch semantics and tests belong to Docs #483/#484; Cloud owns public status and CORS. This consumer renders only bounded aggregate labels. diff --git a/docs/portal/assets/identity/status.mjs b/docs/portal/assets/identity/status.mjs new file mode 100644 index 00000000..2659eae6 --- /dev/null +++ b/docs/portal/assets/identity/status.mjs @@ -0,0 +1,109 @@ +// Shared public footer summary. No component rows, credentials or telemetry. +export const STATUS_API = 'https://status.durable-workflow.com/api/current.json'; +const statuses = new Set(['ok', 'warning', 'failed', 'unknown']); +const levels = new Set(['ok', 'warning', 'critical', 'unknown']); +const unknown = () => ({state: 'unknown', validUntil: 0}); +function timestamp(value) { + if (typeof value !== 'string') return Number.NaN; + const match = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)(?:\.\d{1,9})?(Z|([+-])(\d\d):(\d\d))$/.exec(value); + if (!match) return Number.NaN; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return Number.NaN; + const offsetMinutes = match[2] === 'Z' ? 0 : Number(match[4]) * 60 + Number(match[5]); + const offsetSign = match[3] === '-' ? -1 : 1; + // Date.parse normalizes impossible days and 24:00. Round-trip the supplied + // calendar fields in their original offset before accepting the evidence. + const suppliedCalendar = new Date(parsed + offsetMinutes * offsetSign * 60000).toISOString().slice(0, 19); + return suppliedCalendar === match[1] ? parsed : Number.NaN; +} + +function evidenceWindow(snapshot, now) { + if (snapshot?.schema !== 'durable_workflow.status_page_snapshot.v1' + || snapshot.product !== 'cloud' || snapshot.environment !== 'prod' || !Number.isFinite(now)) return unknown(); + const checked = timestamp(snapshot.checked_at); + const generated = timestamp(snapshot.generated_at); + const freshness = snapshot.freshness; + if (!Number.isFinite(checked) || !Number.isFinite(generated) || checked > now + || generated > now || generated < checked || !freshness + || !Number.isFinite(freshness.stale_after_seconds) || freshness.stale_after_seconds <= 0 + || freshness.stale_after_seconds > 900 || !Number.isFinite(freshness.age_seconds) + || freshness.age_seconds < 0) return unknown(); + const validUntil = checked + freshness.stale_after_seconds * 1000; + if (freshness.status === 'stale' || now >= validUntil || freshness.age_seconds >= freshness.stale_after_seconds) return {state: 'stale', validUntil: 0}; + if (freshness.status !== 'fresh') return unknown(); + return {validUntil}; +} + +function aggregateState(snapshot, validUntil) { + const impact = snapshot.customer_impact; + if (!statuses.has(snapshot.status) || !levels.has(snapshot.alert_level) + || !impact || !statuses.has(impact.status) || !levels.has(impact.alert_level)) return unknown(); + const reason = snapshot.meta?.public_status_reason; + if (reason === 'cloud_unreachable') return {state: 'unavailable', validUntil}; + if (reason === 'monitor_stale' || reason === 'probe_unverified') return {state: 'unverified', validUntil}; + if (reason === 'publisher_stale') return {state: 'stale', validUntil: 0}; + if (reason !== undefined && reason !== null && reason !== '') return unknown(); + const stateTokens = new Set([snapshot.status, impact.status]); + const levelTokens = new Set([snapshot.alert_level, impact.alert_level]); + if (stateTokens.has('failed') || levelTokens.has('critical')) return {state: 'incident', validUntil}; + if (stateTokens.has('warning') || levelTokens.has('warning')) { + const families = impact.affected_families; + const maintenanceOnly = Array.isArray(families) && families.length === 1 && families[0] === 'maintenance'; + return {state: maintenanceOnly ? 'maintenance' : 'degraded', validUntil}; + } + if (stateTokens.has('unknown') || levelTokens.has('unknown')) return {state: 'unknown', validUntil}; + return {state: 'operational', validUntil}; +} + +export function publicStatus(snapshot, now = Date.now()) { + const evidence = evidenceWindow(snapshot, now); + return evidence.state ? evidence : aggregateState(snapshot, evidence.validUntil); +} + +export async function fetchPublicStatus({fetchImpl = globalThis.fetch, now = Date.now, signal} = {}) { + const response = await fetchImpl(STATUS_API, { + method: 'GET', mode: 'cors', credentials: 'omit', cache: 'no-store', + redirect: 'error', referrerPolicy: 'no-referrer', signal, + }); + if (!response.ok) throw new Error('Public status unavailable'); + const body = await response.text(); + if (body.length > 65536) throw new Error('Public status response too large'); + return publicStatus(JSON.parse(body), now()); +} + +// At most one request at a time, five-second deadline, sixty-second cadence. +// Invalidate a previously successful result at its evidence cutoff, even if the +// next request hangs or the tab has been backgrounded. The caller owns cleanup. +export function watchPublicStatus(onChange, {fetchImpl = globalThis.fetch, now = Date.now} = {}) { + let stopped = false; + let controller; + let timer; + let expiry; + const publish = summary => { + clearTimeout(expiry); + onChange(summary.state); + if (summary.validUntil > now()) { + expiry = setTimeout(() => onChange('stale'), summary.validUntil - now()); + } + }; + const refresh = async () => { + controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + const summary = await fetchPublicStatus({fetchImpl, now, signal: controller.signal}); + if (!stopped) publish(summary); + } catch { + if (!stopped) publish(unknown()); + } finally { + clearTimeout(timeout); + if (!stopped) timer = setTimeout(refresh, 60000); + } + }; + void refresh(); + return () => { + stopped = true; + controller?.abort(); + clearTimeout(timer); + clearTimeout(expiry); + }; +} diff --git a/docs/portal/assets/portal.css b/docs/portal/assets/portal.css index f6af6a3e..da6847ea 100644 --- a/docs/portal/assets/portal.css +++ b/docs/portal/assets/portal.css @@ -1,216 +1,86 @@ -:root { - color-scheme: dark; - --ink: #f7f7f2; - --muted: #adbac8; - --faint: #6f8192; - --canvas: #0b1118; - --surface: #101820; - --surface-raised: #162330; - --line: #263746; - --mint: #9effc7; - --mint-ink: #082016; - --periwinkle: #8da2ff; - --amber: #ffc978; - --content: 1160px; - --reading: 760px; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -* { box-sizing: border-box; } -html { scroll-behavior: smooth; } -body { background: var(--canvas); color: var(--ink); margin: 0; min-width: 0; } -body::before { - background: radial-gradient(circle at 78% 5%, rgb(141 162 255 / 12%), transparent 34rem); - content: ""; - height: 46rem; - pointer-events: none; - position: fixed; - right: 0; - top: 0; - width: 60rem; - z-index: -1; -} -a { color: inherit; } -button, input { font: inherit; } -img, svg { max-width: 100%; } -.sr-only { clip: rect(0, 0, 0, 0); clip-path: inset(50%); height: 1px; overflow: hidden; position: absolute; white-space: nowrap; width: 1px; } -.skip-link { background: var(--mint); color: var(--mint-ink); left: 1rem; padding: .65rem 1rem; position: fixed; top: -5rem; z-index: 1000; } -.skip-link:focus { top: 1rem; } - -main :is(h1, h2, h3, h4, h5, h6)[id] { scroll-margin-top: 96px; } - -.site-header { backdrop-filter: blur(18px); background: rgb(11 17 24 / 82%); border-bottom: 1px solid rgb(255 255 255 / 8%); position: sticky; top: 0; z-index: 100; } -.header-inner { align-items: center; display: flex; gap: 2rem; justify-content: space-between; margin: 0 auto; max-width: var(--content); min-height: 72px; padding: 0 24px; } -.brand { align-items: center; display: inline-flex; gap: .7rem; text-decoration: none; white-space: nowrap; } -.brand-mark { align-items: center; background: var(--mint); border-radius: 8px; color: var(--mint-ink); display: inline-flex; font-size: .68rem; font-weight: 900; height: 32px; justify-content: center; letter-spacing: 0; width: 32px; } -.brand-name { font-size: .96rem; font-weight: 720; letter-spacing: 0; } -.brand-product { border-left: 1px solid var(--line); color: var(--mint); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; padding-left: .7rem; } -.primary-nav { align-items: center; display: flex; gap: .35rem; } -.primary-nav a { border-radius: 8px; color: var(--muted); font-size: .88rem; font-weight: 620; padding: .62rem .75rem; text-decoration: none; } -.primary-nav a:hover, .primary-nav a:focus-visible { background: var(--surface-raised); color: var(--ink); } -.primary-nav .api-link { border: 1px solid var(--line); color: var(--ink); margin-left: .3rem; } -.nav-toggle { background: transparent; border: 0; display: none; padding: .5rem; } -.nav-toggle > span[aria-hidden] { background: var(--ink); display: block; height: 2px; margin: 4px 0; width: 22px; } - -.hero { margin: 0 auto; max-width: var(--content); padding: 112px 24px 76px; } -.hero-grid { align-items: center; display: grid; gap: clamp(3rem, 8vw, 7rem); grid-template-columns: minmax(0, 1.1fr) minmax(330px, .9fr); } -.eyebrow { color: var(--mint); font-size: .74rem; font-weight: 800; letter-spacing: 0; margin-bottom: 1rem; text-transform: uppercase; } -.release-kicker { align-items: center; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); display: inline-flex; font: .74rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; gap: .55rem; margin-bottom: 1.5rem; padding: .48rem .75rem; } -.release-kicker::before { background: var(--mint); border-radius: 50%; box-shadow: 0 0 0 5px rgb(158 255 199 / 9%); content: ""; height: 7px; width: 7px; } -.hero h1 { font-size: 5.5rem; letter-spacing: 0; line-height: .91; margin: 0; max-width: 780px; } -.hero h1 em { color: var(--mint); display: block; font-style: normal; } -.hero-copy { color: var(--muted); font-size: 1.2rem; line-height: 1.7; margin: 1.8rem 0 0; max-width: 610px; } -.hero-actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 2rem; } -.button { align-items: center; border: 1px solid var(--line); border-radius: 8px; display: inline-flex; font-size: .9rem; font-weight: 760; gap: .55rem; justify-content: center; min-height: 48px; padding: .75rem 1rem; text-decoration: none; } -.button.primary { background: var(--mint); border-color: var(--mint); color: var(--mint-ink); } -.button.secondary { background: var(--surface); } -.button:hover { transform: translateY(-1px); } -.hero-terminal { background: #080d12; border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 28px 70px rgb(0 0 0 / 35%); min-width: 0; overflow: hidden; transform: rotate(1deg); } -.terminal-bar { align-items: center; border-bottom: 1px solid var(--line); color: var(--faint); display: flex; font: .72rem ui-monospace, monospace; gap: .4rem; padding: .8rem 1rem; } -.terminal-bar i { background: #ff6c68; border-radius: 50%; display: block; height: 8px; width: 8px; } -.terminal-bar i:nth-child(2) { background: var(--amber); } -.terminal-bar i:nth-child(3) { background: var(--mint); } -.terminal-bar span { margin-left: .45rem; } -.hero-terminal pre { color: #dce5ed; font: .78rem/1.75 ui-monospace, SFMono-Regular, Menlo, monospace; margin: 0; overflow-x: auto; padding: 1.35rem; } -.hero-terminal .prompt { color: var(--mint); } -.hero-terminal .result { color: var(--periwinkle); } - -.home-section { margin: 0 auto; max-width: var(--content); padding: 80px 24px; } -.home-section + .home-section { border-top: 1px solid rgb(255 255 255 / 7%); } -.section-heading { align-items: end; display: flex; gap: 2rem; justify-content: space-between; margin-bottom: 2.4rem; } -.section-heading h2 { font-size: 3.6rem; letter-spacing: 0; line-height: 1; margin: 0; max-width: 670px; } -.section-heading p { color: var(--muted); line-height: 1.65; margin: 0; max-width: 420px; } -.path-grid { display: grid; gap: 1rem; grid-template-columns: repeat(3, 1fr); } -.path-card, .deployment-card { background: linear-gradient(145deg, rgb(22 35 48 / 95%), rgb(16 24 32 / 92%)); border: 1px solid var(--line); border-radius: 8px; display: flex; flex-direction: column; min-height: 260px; padding: 1.55rem; text-decoration: none; transition: border-color .16s ease, transform .16s ease; } -.path-card:hover, .deployment-card:hover { border-color: var(--mint); transform: translateY(-3px); } -.path-card .number { color: var(--faint); font: .7rem ui-monospace, monospace; letter-spacing: 0; } -.path-card h3, .deployment-card h3 { font-size: 1.45rem; letter-spacing: 0; margin: auto 0 .6rem; } -.path-card p, .deployment-card p { color: var(--muted); line-height: 1.55; margin: 0; } -.path-card .arrow, .deployment-card .arrow { color: var(--mint); font-size: 1.2rem; margin-top: 1.2rem; } -.deployment-card .dw-cloud-promotion__action { align-self: flex-start; font-size: .9rem; font-weight: 760; text-underline-offset: 3px; } -.deployment-grid { display: grid; gap: 1rem; grid-template-columns: repeat(2, 1fr); } -.deployment-card { min-height: 220px; } -.deployment-card .tag { color: var(--periwinkle); font-size: .72rem; font-weight: 760; letter-spacing: 0; text-transform: uppercase; } -.capability-strip { background: var(--mint); color: var(--mint-ink); overflow: hidden; } -.capability-strip div { display: flex; font-size: .78rem; font-weight: 850; gap: 2.6rem; justify-content: center; letter-spacing: 0; padding: 1rem 2rem; text-transform: uppercase; white-space: nowrap; } -.capability-strip span::before { content: "•"; margin-right: 2.6rem; } - -.docs-shell { display: grid; grid-template-columns: 250px minmax(0, var(--reading)); gap: clamp(2.5rem, 7vw, 6rem); justify-content: center; margin: 0 auto; max-width: var(--content); padding: 52px 24px 96px; } -.docs-sidebar { align-self: start; max-height: calc(100vh - 105px); overflow-y: auto; position: sticky; top: 96px; } -.release-chip { border: 1px solid var(--line); border-radius: 8px; color: var(--muted); font: .72rem ui-monospace, monospace; margin-bottom: 1.75rem; padding: .65rem .75rem; } -.release-chip span { color: var(--mint); margin-right: .4rem; } -.docs-sidebar section + section { margin-top: 1.35rem; } -.docs-sidebar h2 { color: var(--faint); font-size: .68rem; letter-spacing: 0; margin: 0 0 .4rem; text-transform: uppercase; } +main :is(h1,h2,h3,h4,h5,h6)[id] { scroll-margin-top: 24px; } +.hero { max-width: 1200px; margin: auto; padding: 72px 24px 0; } +.hero-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 380px); gap: 48px; align-items: center; } +.release-kicker, .eyebrow { font: 500 12px var(--mono); letter-spacing: .08em; color: var(--action); text-transform: uppercase; } +.release-kicker { margin-bottom: 22px; } +.hero h1 { font-size: clamp(48px, 5vw, 64px); line-height: 1; letter-spacing: -.03em; margin: 0; } +.hero h1 em { font-style: normal; } +.hero-copy { font-size: 19px; line-height: 1.55; color: var(--ink2); margin: 22px 0; max-width: 580px; } +.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; } +.hero-terminal { margin: 0; } +.terminal-note { font-size: 13px; color: var(--muted); margin: 10px 0 0; } +.home-section { max-width: 1200px; margin: 88px auto 0; padding: 0 24px; } +.home-section:last-child { margin-bottom: 96px; } +.section-heading { margin-bottom: 24px; } +.section-heading h2 { font-size: 36px; line-height: 1.15; letter-spacing: -.02em; margin: 0 0 8px; } +.section-heading p { font-size: 16px; color: var(--ink2); margin: 0; } +.path-grid, .deployment-grid { display: grid; gap: 16px; grid-template-columns: repeat(3, minmax(0, 1fr)); } +.deployment-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.path-card, .deployment-card { display: flex; flex-direction: column; gap: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 24px; color: var(--ink); text-decoration: none; } +.path-card:hover, .deployment-card:hover { border-color: var(--action); } +.path-card .number, .deployment-card .tag { font: 400 12px var(--mono); letter-spacing: .08em; color: var(--action); text-transform: uppercase; } +.path-card h3 { font-size: 20px; margin: 0; line-height: 1.3; } +.path-card p { font-size: 14px; line-height: 1.5; color: var(--ink2); margin: 0; flex: 1; } +.path-card .arrow, .deployment-card .arrow { font-size: 14px; color: var(--action); font-weight: 600; margin-top: 8px; } +.deployment-card { padding: 28px; gap: 10px; } +.deployment-card h3 { font-size: 22px; margin: 0; } +.deployment-card p { font-size: 15px; color: var(--ink2); margin: 0; line-height: 1.55; } +.deployment-card .tag { color: var(--muted); } +.deployment-card .arrow { font-size: 15px; align-self: flex-start; text-decoration: none; } +.docs-shell { display: grid; grid-template-columns: 230px minmax(0, 760px); gap: 64px; max-width: 1200px; margin: auto; padding: 48px 24px 96px; } +.docs-sidebar { align-self: start; position: sticky; top: 24px; max-height: calc(100vh - 48px); overflow-y: auto; } +.release-chip { font: 400 12px var(--mono); color: var(--muted); border: 1px solid var(--line); border-radius: 8px; margin-bottom: 24px; padding: 10px 12px; } +.release-chip span { color: var(--action); } +.docs-sidebar section + section { margin-top: 20px; } +.docs-sidebar h2 { color: var(--muted); font: 400 12px var(--mono); text-transform: uppercase; letter-spacing: .08em; margin: 0 0 6px; } .docs-sidebar ul { list-style: none; margin: 0; padding: 0; } -.docs-sidebar a { border-left: 2px solid transparent; color: var(--muted); display: block; font-size: .83rem; line-height: 1.35; padding: .42rem .65rem; text-decoration: none; } -.docs-sidebar a:hover { color: var(--ink); } -.docs-sidebar a[aria-current="page"] { background: rgb(158 255 199 / 7%); border-color: var(--mint); color: var(--mint); } +.docs-sidebar a { display: block; color: var(--ink2); text-decoration: none; font-size: 14px; padding: 6px 10px; border-radius: 6px; } +.docs-sidebar a:hover, .docs-sidebar a[aria-current='page'] { background: var(--tint); color: var(--action); } .guide-content { min-width: 0; } -.guide-content > h1 { font-size: 4.8rem; letter-spacing: 0; line-height: .96; margin: 0 0 1.25rem; } -.guide-content .lead { color: var(--muted); font-size: 1.2rem; line-height: 1.65; margin: 0 0 3rem; max-width: 700px; } -.guide-content h2 { border-top: 1px solid var(--line); font-size: 1.75rem; letter-spacing: 0; margin: 3.5rem 0 1rem; padding-top: 2rem; } -.guide-content h3 { font-size: 1.16rem; margin: 2.2rem 0 .8rem; } -.guide-content p, .guide-content li { color: #c6d0da; font-size: .98rem; line-height: 1.75; } -.guide-content li + li { margin-top: .45rem; } -.guide-content a:not(.button):not(.next-links a) { color: var(--mint); text-underline-offset: 3px; } -.guide-content code:not(pre code) { background: var(--surface-raised); border: 1px solid var(--line); border-radius: 5px; color: #f1d5a7; font: .86em ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; padding: .15rem .32rem; word-break: break-word; } -.guide-content table { border-collapse: collapse; display: block; margin: 1.4rem 0; max-width: 100%; overflow-x: auto; } -.guide-content th, .guide-content td { border-bottom: 1px solid var(--line); padding: .75rem 1rem; text-align: left; vertical-align: top; } -.guide-content th { color: var(--ink); font-size: .8rem; } -.guide-content td { color: var(--muted); font-size: .9rem; } -.guide-content blockquote { border-left: 3px solid var(--periwinkle); color: var(--muted); margin: 1.5rem 0; padding: .35rem 1.2rem; } -.code-stage { background: #080d12; border: 1px solid var(--line); border-radius: 8px; margin: 1.35rem 0; max-width: 100%; overflow: hidden; } -.code-stage__bar { align-items: center; border-bottom: 1px solid var(--line); color: var(--faint); display: flex; font: .72rem ui-monospace, SFMono-Regular, Menlo, monospace; justify-content: space-between; padding: .55rem .8rem .55rem 1rem; } -.copy-button { background: transparent; border: 1px solid var(--line); border-radius: 5px; color: var(--muted); cursor: pointer; font-size: .68rem; padding: .3rem .5rem; } -.copy-button:hover { color: var(--ink); } -.code-stage pre, .guide-content > pre, .guide-content li > pre { margin: 0; max-width: 100%; overflow-x: auto; padding: 1.05rem; } -.code-stage code, .guide-content > pre code, .guide-content li > pre code { color: #dce5ed; font: .82rem/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; } -.guide-content > pre, .guide-content li > pre { background: #080d12; border: 1px solid var(--line); border-radius: 8px; } -.code-disclosure { border: 1px solid var(--line); border-radius: 8px; margin: 1rem 0; overflow: hidden; } -.code-disclosure > summary { background: var(--surface); color: var(--muted); cursor: pointer; line-height: 1.45; padding: .9rem 1rem; } -.code-disclosure > summary strong { color: var(--ink); } -.code-disclosure[open] > summary { border-bottom: 1px solid var(--line); } +.guide-content > h1 { font-size: clamp(38px, 4vw, 56px); letter-spacing: -.03em; line-height: 1.08; margin: 16px 0; overflow-wrap: anywhere; } +.guide-content .lead { font-size: 19px; line-height: 1.55; color: var(--ink2); margin: 0 0 40px; } +.guide-content h2 { font-size: 32px; line-height: 1.2; letter-spacing: -.02em; margin: 48px 0 16px; padding-top: 24px; border-top: 1px solid var(--line); } +.guide-content h3 { font-size: 20px; margin: 28px 0 12px; } +.guide-content p, .guide-content li { font-size: 16px; line-height: 1.6; color: var(--ink2); } +.guide-content li + li { margin-top: 6px; } +.guide-content code:not(pre code) { font: 400 .85em var(--mono); color: var(--ink); background: var(--tint); border-radius: 4px; padding: 2px 5px; overflow-wrap: anywhere; } +.guide-content table { border-collapse: collapse; display: block; max-width: 100%; overflow-x: auto; margin: 24px 0; font-size: 14px; } +.guide-content th, .guide-content td { border-bottom: 1px solid var(--line); padding: 12px; text-align: left; vertical-align: top; } +.guide-content td { color: var(--ink2); } +.guide-content blockquote { border: 1px solid var(--line); border-radius: 8px; padding: 4px 20px; margin: 24px 0; background: var(--surface); } +.guide-content pre { background: var(--code-bg); border-radius: 8px; max-width: 100%; overflow-x: auto; padding: 16px; color: var(--code-ink); } +.guide-content pre code { font: 400 13px/1.7 var(--mono); color: var(--code-ink); } +.code-disclosure, .outcome, .note, .warning, .choice { border: 1px solid var(--line); border-radius: 8px; margin: 20px 0; padding: 16px; background: var(--surface); } +.code-disclosure { padding: 0; overflow: hidden; } +.code-disclosure > summary { padding: 16px; color: var(--ink2); } .code-disclosure .code-stage { border: 0; border-radius: 0; margin: 0; } -.outcome, .note, .warning { border: 1px solid var(--line); border-radius: 8px; margin: 1.4rem 0; padding: 1rem 1.1rem; } -.outcome { background: rgb(158 255 199 / 7%); border-color: rgb(158 255 199 / 32%); } -.note { background: rgb(141 162 255 / 7%); border-color: rgb(141 162 255 / 32%); } -.warning { background: rgb(255 201 120 / 7%); border-color: rgb(255 201 120 / 36%); } -.outcome strong, .note strong, .warning strong { color: var(--ink); display: block; font-size: .78rem; letter-spacing: 0; margin-bottom: .35rem; text-transform: uppercase; } +.outcome { background: var(--tint); } .outcome p, .note p, .warning p { margin: 0; } -.choice-grid { display: grid; gap: .85rem; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 1.3rem 0; } -.choice { border: 1px solid var(--line); border-radius: 8px; padding: 1rem; } -.choice h3 { margin: 0 0 .4rem; } -.choice p { font-size: .9rem; margin: 0; } -.registration-options .choice { min-width: 0; } -.registration-options p { margin-bottom: .8rem; } -.registration-options pre { background: #080d12; border: 1px solid var(--line); border-radius: 8px; margin: 0; max-width: 100%; overflow-x: auto; padding: .85rem; } -.registration-options code { color: #dce5ed; font: .75rem/1.65 ui-monospace, SFMono-Regular, Menlo, monospace; } +.outcome strong, .note strong, .warning strong { display: block; margin-bottom: 6px; color: var(--ink); font-size: 14px; } +.choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.choice { margin: 0; min-width: 0; } .choice h3 { margin-top: 0; } .steps { counter-reset: stages; list-style: none; padding: 0; } -.steps > li { counter-increment: stages; padding-left: 3rem; position: relative; } -.steps > li + li { margin-top: 2rem; } -.steps > li::before { align-items: center; background: var(--mint); border-radius: 50%; color: var(--mint-ink); content: counter(stages); display: flex; font-size: .75rem; font-weight: 850; height: 1.8rem; justify-content: center; left: 0; position: absolute; top: .1rem; width: 1.8rem; } +.steps > li { counter-increment: stages; padding-left: 48px; position: relative; } +.steps > li + li { margin-top: 28px; } +.steps > li::before { content: counter(stages); position: absolute; left: 0; top: 0; width: 30px; height: 30px; display: grid; place-items: center; background: var(--tint); color: var(--action); border-radius: 50%; font: 500 13px var(--mono); } .steps h3 { margin-top: 0; } -.next-links { border-top: 1px solid var(--line); display: grid; gap: 1rem; grid-template-columns: repeat(2, 1fr); margin-top: 4rem; padding-top: 1.5rem; } -.next-links a { border: 1px solid var(--line); border-radius: 8px; display: flex; flex-direction: column; font-size: .9rem; gap: .2rem; padding: .85rem 1rem; text-decoration: none; } -.next-links a:hover { border-color: var(--mint); } -.next-links span { color: var(--faint); font-size: .68rem; letter-spacing: 0; text-transform: uppercase; } - -.site-footer { border-top: 1px solid var(--line); color: var(--muted); display: flex; gap: 3rem; justify-content: space-between; margin: 0 auto; max-width: var(--content); padding: 3rem 24px; } -.site-footer strong { color: var(--ink); } -.site-footer p { font-size: .82rem; margin: .45rem 0 0; } -.site-footer nav { align-items: center; display: flex; flex-wrap: wrap; gap: 1.2rem; } -.site-footer a { font-size: .82rem; } - -@media (max-width: 900px) { - .hero { padding-top: 80px; } - .hero-grid { grid-template-columns: 1fr; } - .hero h1 { font-size: 3.5rem; } - .hero-copy { font-size: 1.1rem; } - .hero-terminal { max-width: 620px; transform: none; } - .section-heading { align-items: start; flex-direction: column; gap: 1rem; } - .section-heading h2 { font-size: 2.75rem; } - .docs-shell { gap: 2.5rem; grid-template-columns: 210px minmax(0, 1fr); } - .guide-content > h1 { font-size: 3.25rem; } - .path-grid { grid-template-columns: 1fr; } - .path-card { min-height: 200px; } - .registration-options { grid-template-columns: 1fr; } -} - -@media (max-width: 800px) { - .header-inner { min-height: 64px; padding: 0 18px; position: relative; } - .brand-name { display: none; } - .nav-toggle { display: block; } - .primary-nav { align-items: stretch; background: var(--surface); border: 1px solid var(--line); border-radius: 0 0 8px 8px; box-shadow: 0 18px 38px rgb(0 0 0 / 38%); display: none; flex-direction: column; left: 0; max-height: calc(100vh - 64px); overflow-y: auto; padding: .7rem 18px 1rem; position: absolute; right: 0; top: 100%; } - .primary-nav.is-open { display: flex; } - .primary-nav a { min-height: 42px; } - .primary-nav .api-link { margin-left: 0; } - body.nav-open { overflow: hidden; } - .nav-open main, .nav-open .docs-shell, .nav-open .site-footer { visibility: hidden; } -} - -@media (max-width: 700px) { - .hero { padding: 64px 18px 52px; } - .hero h1 { font-size: 3.5rem; } - .hero-copy { font-size: 1.04rem; } - .hero-grid { gap: 2.5rem; } - .hero-terminal { display: none; } - .home-section { padding: 60px 18px; } - .deployment-grid, .choice-grid { grid-template-columns: 1fr; } - .capability-strip div { justify-content: flex-start; overflow: hidden; } - .docs-shell { display: block; padding: 38px 18px 72px; } - .docs-sidebar { max-height: none; overflow: visible; position: static; } - .docs-sidebar section { display: none; } - .release-chip { display: inline-block; margin-bottom: 2rem; } - .section-heading h2 { font-size: 2.25rem; } - .guide-content > h1 { font-size: 2.7rem; } - .guide-content .lead { font-size: 1.08rem; } - .code-stage pre, .guide-content > pre, .guide-content li > pre { padding: .9rem; } - .code-stage code, .guide-content > pre code, .guide-content li > pre code { font-size: .75rem; } - .next-links { grid-template-columns: 1fr; } - .site-footer { align-items: flex-start; flex-direction: column; padding: 2.5rem 18px; } +.next-links { border-top: 1px solid var(--line); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 48px; padding-top: 24px; } +.next-links a { display: flex; flex-direction: column; gap: 4px; border: 1px solid var(--line); border-radius: 8px; padding: 16px; text-decoration: none; font-size: 14px; } +.next-links span { font: 400 12px var(--mono); text-transform: uppercase; color: var(--muted); } +@media (max-width: 1000px) { .hero-grid { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 32px; } .docs-shell { gap: 32px; grid-template-columns: 210px minmax(0, 1fr); } } +@media (max-width: 760px) { + .hero { padding-top: 48px; } .hero-grid { grid-template-columns: 1fr; gap: 32px; } + .hero h1 { font-size: 48px; max-width: 600px; } .hero-copy { font-size: 18px; } + .path-grid { grid-template-columns: 1fr; } .home-section { margin-top: 56px; } + .docs-shell { display: block; padding-top: 28px; } + .docs-sidebar { position: static; max-height: none; border-bottom: 1px solid var(--line); padding-bottom: 24px; margin-bottom: 32px; } + .docs-sidebar section { display: inline-block; vertical-align: top; width: 49%; } .docs-sidebar section + section { margin-top: 0; margin-bottom: 16px; } } - -@media (prefers-reduced-motion: reduce) { - html { scroll-behavior: auto; } - *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } +@media (max-width: 600px) { + .hero, .home-section { padding-inline: 18px; } .hero h1 { font-size: 44px; } + .section-heading h2 { font-size: 32px; } + .deployment-grid, .choice-grid, .next-links { grid-template-columns: 1fr; } + .docs-shell { padding-inline: 18px; } .guide-content h2 { font-size: 28px; } + .home-section:last-child { margin-bottom: 64px; } } +.hero-grid > div { min-width: 0; } diff --git a/docs/portal/assets/portal.js b/docs/portal/assets/portal.js index e78ffe0c..24ef32e9 100644 --- a/docs/portal/assets/portal.js +++ b/docs/portal/assets/portal.js @@ -1,31 +1,63 @@ (() => { 'use strict'; - const toggle = document.querySelector('.nav-toggle'); const navigation = document.querySelector('.primary-nav'); - if (toggle && navigation) { - const setNavigationOpen = (open) => { - toggle.setAttribute('aria-expanded', String(open)); - toggle.setAttribute('aria-label', open ? 'Close navigation' : 'Open navigation'); - navigation.classList.toggle('is-open', open); - document.body.classList.toggle('nav-open', open); - }; - toggle.addEventListener('click', () => setNavigationOpen(toggle.getAttribute('aria-expanded') !== 'true')); - document.addEventListener('keydown', (event) => { - if (event.key === 'Escape' && toggle.getAttribute('aria-expanded') === 'true') { - setNavigationOpen(false); - toggle.focus(); - } - }); + const closeNavigation = () => { + toggle?.setAttribute('aria-expanded', 'false'); + toggle?.setAttribute('aria-label', 'Open navigation'); + navigation?.classList.remove('is-open'); + }; + toggle?.addEventListener('click', () => { + const open = toggle.getAttribute('aria-expanded') !== 'true'; + toggle.setAttribute('aria-expanded', String(open)); + toggle.setAttribute('aria-label', open ? 'Close navigation' : 'Open navigation'); + navigation?.classList.toggle('is-open', open); + }); + const switcher = document.querySelector('.sdk-switcher'); + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + if (switcher?.open) { switcher.open = false; switcher.querySelector('summary').focus(); } + else if (toggle?.getAttribute('aria-expanded') === 'true') { closeNavigation(); toggle.focus(); } + }); + document.addEventListener('click', (event) => { + if (switcher?.open && !switcher.contains(event.target)) switcher.open = false; + }); + const theme = document.querySelector('.theme-toggle'); + const syncTheme = () => { + const dark = document.documentElement.dataset.theme === 'dark'; + theme?.setAttribute('aria-pressed', String(dark)); + theme?.setAttribute('aria-label', dark ? 'Switch to light theme' : 'Switch to dark theme'); + if (theme) theme.textContent = dark ? 'Light' : 'Dark'; + }; + theme?.addEventListener('click', () => { + const selected = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; + document.documentElement.dataset.theme = selected; + try { localStorage.setItem('durable-workflow-theme', selected); } catch (_) { /* Theme still works for this page. */ } + syncTheme(); + }); + syncTheme(); + // Markdown and generated reference blocks get the same working copy control. + for (const pre of document.querySelectorAll('pre')) { + if (!pre.querySelector('code') || pre.closest('.code-stage')) continue; + const stage = document.createElement('div'); stage.className = 'code-stage'; + pre.before(stage); + const bar = document.createElement('div'); bar.className = 'code-stage__bar'; + const label = document.createElement('span'); label.textContent = 'Code'; + const copy = document.createElement('button'); copy.className = 'copy-button'; copy.type = 'button'; copy.textContent = 'Copy'; + bar.append(label, copy); stage.append(bar, pre); } - for (const button of document.querySelectorAll('.copy-button')) { + button.setAttribute('aria-live', 'polite'); button.addEventListener('click', async () => { const source = button.closest('.code-stage')?.querySelector('code')?.textContent; if (!source) return; - await navigator.clipboard.writeText(source); - button.textContent = 'Copied'; - window.setTimeout(() => { button.textContent = 'Copy'; }, 1600); + try { await navigator.clipboard.writeText(source); button.textContent = 'Copied'; } + catch (error) { + // Expose a bounded permission hint, without rendering browser-supplied error text. + button.textContent = error instanceof DOMException && error.name === 'NotAllowedError' + ? 'Clipboard blocked' : 'Copy unavailable'; + } + window.setTimeout(() => { button.textContent = 'Copy'; }, 1800); }); } })(); diff --git a/docs/portal/assets/social-card.png b/docs/portal/assets/social-card.png index 291e2058..94825398 100644 Binary files a/docs/portal/assets/social-card.png and b/docs/portal/assets/social-card.png differ diff --git a/docs/portal/assets/social-card.svg b/docs/portal/assets/social-card.svg index 0a283c07..0c12f063 100644 --- a/docs/portal/assets/social-card.svg +++ b/docs/portal/assets/social-card.svg @@ -1,31 +1,13 @@ - Durable Workflow PHP SDK - Build, test, and operate durable workflows in PHP. - - - - - - - - - - - - - - - - - - - - - - - DURABLE WORKFLOW - PHP SDK - Build. Test. Operate. Keep going. - - php.durable-workflow.com +Durable Workflow / phpWork that keeps going. Even when PHP stops. + + + +Durable Workflow +/php +Work that keeps going. +Even when PHP stops. +Plain PHP · Laravel · Symfony + +php.durable-workflow.com diff --git a/docs/portal/assets/status.js b/docs/portal/assets/status.js new file mode 100644 index 00000000..26991da3 --- /dev/null +++ b/docs/portal/assets/status.js @@ -0,0 +1,26 @@ +import {watchPublicStatus} from './identity/status.mjs'; + +const pill = document.querySelector('.status-pill'); +const label = pill?.querySelector('.status-label'); +const labels = { + operational: 'All systems operational', degraded: 'Service degraded', + incident: 'Service incident', maintenance: 'Maintenance', stale: 'Status stale', + unverified: 'Status unverified', unavailable: 'Status unavailable', unknown: 'Status unknown', +}; +let cleanup; +const show = (state) => { + const selected = Object.hasOwn(labels, state) ? state : 'unknown'; + if (!pill || !label) return; + pill.dataset.state = selected; + if (label.textContent !== labels[selected]) label.textContent = labels[selected]; +}; +const start = () => { + cleanup?.(); + show('unknown'); + cleanup = watchPublicStatus(show); +}; +if (pill && label) { + start(); + window.addEventListener('pagehide', () => { cleanup?.(); cleanup = undefined; show('unknown'); }); + window.addEventListener('pageshow', event => { if (event.persisted) start(); }); +} diff --git a/docs/portal/assets/theme.js b/docs/portal/assets/theme.js new file mode 100644 index 00000000..ac5a1701 --- /dev/null +++ b/docs/portal/assets/theme.js @@ -0,0 +1,6 @@ +(() => { + // Light is the default. Dark is a deliberate, persistent user choice. + try { + if (localStorage.getItem('durable-workflow-theme') === 'dark') document.documentElement.dataset.theme = 'dark'; + } catch (_) { /* A blocked storage API keeps the light default. */ } +})(); diff --git a/docs/portal/index.md b/docs/portal/index.md index cd77df56..e646ce8c 100644 --- a/docs/portal/index.md +++ b/docs/portal/index.md @@ -15,75 +15,64 @@ description: Build, test, deploy, and operate durable PHP workflows with first-p Start with plain PHP -
-
first-workflow
-
$ {{ release.composerCommand }}
-$ env -u DURABLE_WORKFLOW_CLIENT_TOKEN php worker.php &
-✓ worker registered · queue php-quickstart
-$ env -u DURABLE_WORKFLOW_WORKER_TOKEN php client.php
-{"workflow_id":"php-quickstart-a83f…","result":{"greeting":"hello, PHP"}}
+
+
+
first-workflow
+
{{ release.composerCommand }}
+env -u DURABLE_WORKFLOW_CLIENT_TOKEN \
+  php worker.php &
+env -u DURABLE_WORKFLOW_WORKER_TOKEN \
+  php client.php
+
+

After configuring the runtime and saving the example files.

-
WorkflowsActivitiesSignalsQueriesUpdatesRetries
-
-

Start where your PHP already lives.

+

Start where your PHP already lives

The durable model is shared. The bootstrap, container, and operating experience stay idiomatic to your stack.

- 01 / LARAVEL + 01 · LARAVEL

Move from v1 without losing Laravel

Choose v2 embedded or service mode now, then change ownership later through one supported transition matrix.

- + Laravel guide →
- 02 / PLAIN PHP + 02 · PLAIN PHP

Client + remote worker

Use the framework-neutral SDK in a script, service, or existing application.

- + Plain PHP guide →
- 03 / SYMFONY + 03 · SYMFONY

Container-managed worker

Configure the first-party SDK as services and run a Console command under your supervisor.

- + Symfony guide →
-

Choose who operates the runtime.

+

Choose who runs the runtime

Your workflow code and task queue stay the same. The runtime URL, credentials, and operating boundary change.

+ + Your infrastructure · no account +

Self-hosted Server

+

Run the published Server image with your database, auth policy, and operational controls.

+ Run Server → +
Managed · limited access

Durable Workflow Cloud

Request a managed namespace, then use its full runtime URL with separate client and worker credentials.

Request early access →
- - Your infrastructure -

Self-hosted Server

-

Run the published Server image with your database, auth policy, and operational controls.

- -
-
-
- -
-
-

Learn one useful layer at a time.

-

Get a result first. Then add durable messages, retry policy, tests, and production operations as your workflow needs them.

-
-
- COMMUNICATE

Signals, queries & updates

Send events, inspect replayed state, and request tracked changes.

→
- PROVE

Replay-safe tests

Test handlers quickly, then qualify the real worker and runtime boundary.

→
- RECOVER

Operate with evidence

Diagnose auth, queue, replay, retry, and version mismatches by symptom.

→
diff --git a/package.json b/package.json index c154f4da..d3bc45e2 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "check:docs-browser": "node scripts/check-docs-browser.mjs build/site", "check:docs-examples": "node scripts/check-docs-examples.mjs", "test:docs-portal-links": "node --test scripts/check-docs-portal-links.test.mjs", - "test:docs-browser-failures": "node --test scripts/check-docs-browser-failures.test.mjs" + "test:docs-browser-failures": "node --test scripts/check-docs-browser-failures.test.mjs", + "test:docs-identity-status": "node --test scripts/check-docs-identity-status.test.mjs" }, "devDependencies": { "@11ty/eleventy": "3.1.7", diff --git a/scripts/bind-docs-artifact.py b/scripts/bind-docs-artifact.py index 5296d61f..4b4beb2a 100644 --- a/scripts/bind-docs-artifact.py +++ b/scripts/bind-docs-artifact.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Bind a rendered site to its verified, unchanged selected Git source.""" +import importlib.util +import os import json import re import subprocess @@ -8,17 +10,46 @@ from pathlib import Path +SOURCE_REVISION = re.compile(r"[0-9a-f]{40}") + + def main() -> int: - if len(sys.argv) != 2 or not re.fullmatch(r"[0-9a-f]{40}", sys.argv[1]): + if len(sys.argv) != 2 or not SOURCE_REVISION.fullmatch(sys.argv[1]): print("Expected the exact selected source commit.", file=sys.stderr) return 1 selected = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() if selected != sys.argv[1] or subprocess.run(["git", "diff", "--exit-code", "HEAD"], check=False).returncode: print("The selected documentation source changed before artifact binding.", file=sys.stderr) return 1 - Path("build/site/deployment-audit.json").write_text(json.dumps({ - "schema": "durable-workflow.sdk-php.docs-deployment/v1", "source_revision": selected, - }) + "\n") + presentation = os.environ.get("DOCS_PRESENTATION_REVISION", selected) + if not SOURCE_REVISION.fullmatch(presentation): + print("The presentation revision is invalid.", file=sys.stderr) + return 1 + if os.environ.get("DOCS_PRESENTATION_DIRECTORY"): + spec = importlib.util.spec_from_file_location("presentation", Path(__file__).with_name("prepare-docs-presentation.py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.verify(Path(os.environ["DOCS_PRESENTATION_DIRECTORY"]), selected, presentation) + audit = { + "schema": "durable-workflow.sdk-php.docs-deployment/v2", + "source_revision": selected, "sdk_source_revision": selected, + "presentation_source_revision": presentation, + } + tag = os.environ.get("STABLE_TAG", "") + if tag: + if not re.fullmatch(r"\d+\.\d+\.\d+", tag, re.ASCII): + print("The published SDK tag is invalid.", file=sys.stderr) + return 1 + audit["published_sdk_source_revision"] = subprocess.check_output( + ["git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"], text=True, + ).strip() + audit["published_sdk_version"] = tag + workflow = os.environ.get("GITHUB_WORKFLOW_SHA", "") + if workflow: + if not SOURCE_REVISION.fullmatch(workflow): + return 1 + audit["workflow_revision"] = workflow + Path("build/site/deployment-audit.json").write_text(json.dumps(audit) + "\n") return 0 diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh index 86b12345..af8e2e3a 100644 --- a/scripts/build-docs.sh +++ b/scripts/build-docs.sh @@ -7,7 +7,7 @@ export DOCS_SOURCE_DIRECTORY=${DOCS_SOURCE_DIRECTORY:-$PWD} cd "$DOCS_SOURCE_DIRECTORY" "$tooling_directory/vendor/bin/phpdoc" \ -d "$PWD/src" -t "$PWD/build/api" --cache-folder "$PWD/build/phpdoc-cache" \ - --title='Durable Workflow PHP SDK — API Reference' --template="$PWD/.phpdoc/template" + --title='Durable Workflow PHP SDK — API Reference' --template="${DOCS_TEMPLATE_DIRECTORY:-$PWD/.phpdoc/template}" php "$tooling_directory/scripts/finalize-api-reference.php" build/api "$tooling_directory/node_modules/.bin/eleventy" --config="$tooling_directory/.eleventy.cjs" diff --git a/scripts/check-docs-browser.mjs b/scripts/check-docs-browser.mjs index 36e14ee7..3a312e1e 100644 --- a/scripts/check-docs-browser.mjs +++ b/scripts/check-docs-browser.mjs @@ -1,40 +1,54 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; import { once } from 'node:events'; -import net from 'node:net'; +import http from 'node:http'; +import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { chromium } from 'playwright'; -const SITE_HOSTNAME = 'php.durable-workflow.com'; -const siteDirectory = path.resolve(process.argv[2] ?? 'build/site'); - -async function availablePort() { - const server = net.createServer(); - server.unref(); - server.listen(0, '127.0.0.1'); - await once(server, 'listening'); - const address = server.address(); - assert(address && typeof address === 'object'); - const {port} = address; - await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); - return port; +// Loopback is a secure browser context for testing the real clipboard API. +const SITE_HOSTNAME = '127.0.0.1'; +if (process.argv[2] && process.argv[2] !== 'build/site') { + throw new Error('Browser qualification accepts only the repository build/site output.'); } +const siteDirectory = path.resolve('build/site'); +const oneIdentity = fs.existsSync(path.join(siteDirectory, 'assets/identity.css')); -async function waitForServer(port) { - for (let attempt = 0; attempt < 50; attempt += 1) { - try { - await new Promise((resolve, reject) => { - const socket = net.connect(port, '127.0.0.1'); - socket.once('connect', () => { socket.destroy(); resolve(); }); - socket.once('error', reject); - }); - return; - } catch (_error) { - await new Promise((resolve) => setTimeout(resolve, 100)); +const mediaTypes = { + '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', + '.mjs': 'text/javascript', '.json': 'application/json', '.svg': 'image/svg+xml', + '.png': 'image/png', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.ico': 'image/x-icon', +}; + +// Requests select only bytes from this immutable build inventory. No request +// pathname reaches a filesystem operation, including metadata probes. +function documentationFiles(directory = siteDirectory, files = new Map()) { + for (const entry of fs.readdirSync(directory, {withFileTypes: true})) { + const file = path.join(directory, entry.name); + if (entry.isSymbolicLink()) throw new Error('Documentation artifacts must not contain symbolic links.'); + if (entry.isDirectory()) documentationFiles(file, files); + else if (entry.isFile()) { + const url = `/${path.relative(siteDirectory, file).split(path.sep).join('/')}`; + files.set(url, {body: fs.readFileSync(file), type: mediaTypes[path.extname(file)] ?? 'application/octet-stream'}); } } - throw new Error('Documentation server did not start.'); + return files; +} +const documentation = documentationFiles(); + +function serveDocumentation(request, response) { + try { + const pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname); + const key = pathname.endsWith('/') ? `${pathname}index.html` : pathname; + const file = documentation.get(key); + if (!file) throw new Error('Unknown documentation URL'); + response.writeHead(200, {'Content-Type': file.type}); + response.end(file.body); + } catch { + // Missing/invalid URLs are observable HTTP failures in the browser check. + response.writeHead(404, {'Content-Type': 'text/plain'}); + response.end('Not found'); + } } function browserErrors(page, label) { @@ -96,10 +110,80 @@ async function layoutState(page) { }); } -async function validateSurface(browser, origin, surface, viewportName, viewport) { - const context = await browser.newContext({viewport}); +function hasMobileNavigation(surface, viewport) { + return viewport.width <= (oneIdentity ? 920 : 800) && (oneIdentity || surface.kind === 'portal'); +} + +async function qualifyIdentity(page, label, surface, viewport, theme) { + if (oneIdentity) { + assert.notEqual(await page.locator('html').getAttribute('data-theme'), 'dark', `${label} must default to light even with a dark system preference.`); + } + if (hasMobileNavigation(surface, viewport)) await page.locator('.nav-toggle').click(); + if (!oneIdentity) return; + if (theme === 'dark') await page.locator('.theme-toggle').click(); + assert.equal(await page.locator('.theme-toggle').getAttribute('aria-pressed'), String(theme === 'dark')); + assert.equal(await page.locator('body').evaluate(element => getComputedStyle(element).backgroundColor), + theme === 'dark' ? 'rgb(15, 21, 19)' : 'rgb(246, 247, 245)', `${label} must use the shared Paper token.`); + await page.locator('.sdk-switcher summary').focus(); + await page.keyboard.press('Enter'); + assert(await page.locator('.sdk-menu a').first().isVisible(), `${label} SDK switcher must open from the keyboard.`); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('.sdk-switcher').getAttribute('open'), null, `${label} SDK switcher must close with Escape.`); + assert.equal(await page.evaluate(() => document.activeElement === document.querySelector('.sdk-switcher summary')), true); +} + +async function qualifySearch(page, label) { + const search = page.locator('.phpdocumentor-search__field'); + await search.fill('Client'); + await search.press('Enter'); + const dialog = page.locator('.phpdocumentor-search-results'); + await page.waitForFunction(() => !document.querySelector('.phpdocumentor-search-results').classList.contains('phpdocumentor-search-results--hidden')); + assert(await dialog.locator('a[href*="Client"]').count() > 0, `${label} must find generated Client symbols.`); + await page.getByRole('button', {name: 'Close search results'}).click(); + await page.waitForFunction(() => document.querySelector('.phpdocumentor-search-results').classList.contains('phpdocumentor-search-results--hidden')); +} + +async function qualifyApi(page, label, surface, viewportName) { + await page.locator('.phpdocumentor-content').waitFor(); + assert(await page.locator('.phpdocumentor-search__field').isVisible(), `${label} must show API search.`); + assert(await page.locator('.dw-api-guide-link').isVisible(), `${label} must link back to authored guides.`); + if (surface.name === 'API Reference' && ['desktop', 'mobile'].includes(viewportName)) await qualifySearch(page, label); + const sidebarMenu = page.locator('.phpdocumentor-sidebar__menu-icon'); + if (!await sidebarMenu.isVisible()) return; + assert.equal(await sidebarMenu.textContent(), 'Open navigation', `${label} must identify the collapsed API navigation.`); + assert.match(await sidebarMenu.evaluate(element => getComputedStyle(element).letterSpacing), /^(?:normal|0px)$/, `${label} must use normal menu-label spacing.`); + await sidebarMenu.click(); + assert.equal(await sidebarMenu.textContent(), 'Close navigation', `${label} must expose a dismiss action for the API navigation.`); + assert.equal(await page.locator('.phpdocumentor-sidebar__menu-button').getAttribute('aria-expanded'), 'true'); + const title = await page.locator('.phpdocumentor-title__link').evaluate(element => ({ + clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, text: element.textContent.trim(), + })); + assert.equal(title.text, 'Durable Workflow PHP SDK — API Reference', `${label} must retain the API title.`); + assert(title.scrollWidth <= title.clientWidth, `${label} clips the API title while navigation is open.`); + await sidebarMenu.click(); +} + +async function qualifyCopy(page, label) { + const copy = page.locator('.copy-button').first(); + const expected = await copy.evaluate(element => element.closest('.code-stage').querySelector('code').textContent); + await copy.click(); + await page.waitForFunction(() => document.querySelector('.copy-button').textContent === 'Copied'); + assert.equal(await page.evaluate(() => navigator.clipboard.readText()), expected, `${label} must copy exact code text.`); +} + +async function screenshot(page, surface, viewportName, viewport, theme) { + if (!process.env.DOCS_SCREENSHOTS_DIRECTORY || !['home', 'first workflow', 'API Reference'].includes(surface.name) + || !['desktop', 'mobile'].includes(viewportName)) return; + if (hasMobileNavigation(surface, viewport)) await page.locator('.nav-toggle').click(); + fs.mkdirSync(process.env.DOCS_SCREENSHOTS_DIRECTORY, {recursive: true}); + await page.screenshot({path: path.join(process.env.DOCS_SCREENSHOTS_DIRECTORY, + `${surface.name.replaceAll(' ', '-').toLowerCase()}-${viewportName}-${theme}.png`), fullPage: true}); +} + +async function validateSurface(browser, origin, surface, viewportName, viewport, theme) { + const context = await browser.newContext({viewport, colorScheme: 'dark', permissions: ['clipboard-read', 'clipboard-write']}); const page = await context.newPage(); - const label = `${surface.name} ${viewportName}`; + const label = `${surface.name} ${viewportName} ${theme}`; const errors = browserErrors(page, label); await page.route('https://cloud.durable-workflow.com/early-access/promotion-events', route => route.fulfill({ status: 204, @@ -109,6 +193,11 @@ async function validateSurface(browser, origin, surface, viewportName, viewport) vary: 'Origin', }, })); + // A deliberately unqualified fixture keeps status neutral in source layout checks. + await page.route('https://status.durable-workflow.com/api/current.json', route => route.fulfill({ + status: 200, contentType: 'application/json', body: '{}', + headers: {'access-control-allow-origin': origin, 'cache-control': 'no-store'}, + })); await page.route('https://cloudflareinsights.com/cdn-cgi/rum', route => route.fulfill({ status: 204, headers: { @@ -118,36 +207,15 @@ async function validateSurface(browser, origin, surface, viewportName, viewport) })); await page.goto(`${origin}${surface.path}`, {waitUntil: 'networkidle'}); await page.evaluate(() => document.fonts.ready); - - if (surface.kind === 'api') { - await page.locator('.phpdocumentor-content').waitFor(); - assert(await page.locator('.phpdocumentor-search__field').isVisible(), `${label} must show API search.`); - assert(await page.locator('.dw-api-guide-link').isVisible(), `${label} must link back to authored guides.`); - const sidebarMenu = page.locator('.phpdocumentor-sidebar__menu-icon'); - if (await sidebarMenu.isVisible()) { - assert.equal(await sidebarMenu.textContent(), 'Open navigation', `${label} must identify the collapsed API navigation.`); - assert.match(await sidebarMenu.evaluate((element) => getComputedStyle(element).letterSpacing), /^(?:normal|0px)$/, `${label} must use normal menu-label spacing.`); - await sidebarMenu.click(); - assert.equal(await sidebarMenu.textContent(), 'Close navigation', `${label} must expose a dismiss action for the API navigation.`); - assert.equal(await page.locator('.phpdocumentor-sidebar__menu-button').getAttribute('aria-expanded'), 'true', `${label} must expose the expanded API navigation state.`); - const title = await page.locator('.phpdocumentor-title__link').evaluate((element) => ({ - clientWidth: element.clientWidth, - scrollWidth: element.scrollWidth, - text: element.textContent.trim(), - })); - assert.equal(title.text, 'Durable Workflow PHP SDK — API Reference', `${label} must keep the complete API title while navigation is open.`); - assert(title.scrollWidth <= title.clientWidth, `${label} clips the API title while navigation is open.`); - await sidebarMenu.click(); - } - } else { - await page.locator('#main-content').waitFor(); - assert(await page.locator('.brand').isVisible(), `${label} must show the site identity.`); - if (viewport.width <= 800) { - await page.locator('.nav-toggle').click(); - assert(await page.locator('.primary-nav').isVisible(), `${label} mobile navigation must open.`); - } + if (surface.kind === 'portal') await page.locator('#main-content').waitFor(); + await qualifyIdentity(page, label, surface, viewport, theme); + if (surface.kind === 'api') await qualifyApi(page, label, surface, viewportName); + else { + assert(await page.locator('.site-header .brand').isVisible(), `${label} must show the site identity.`); + if (viewport.width <= (oneIdentity ? 920 : 800)) assert(await page.locator('.primary-nav').isVisible(), `${label} mobile navigation must open.`); } - + if (oneIdentity && ['home', 'first workflow'].includes(surface.name)) await qualifyCopy(page, label); + await screenshot(page, surface, viewportName, viewport, theme); const state = await layoutState(page); assert.equal(state.documentWidth, state.viewportWidth, `${label} has horizontal overflow.`); assert(state.title.length > 8, `${label} must have a descriptive title.`); @@ -155,15 +223,17 @@ async function validateSurface(browser, origin, surface, viewportName, viewport) assert.deepEqual(state.unreachable, [], `${label} has controls covered by unrelated content.`); assert.deepEqual(errors, [], `${label} must render without browser or asset errors.`); await context.close(); + return label; } -const port = await availablePort(); +const server = http.createServer(serveDocumentation); +server.listen(0, '127.0.0.1'); +await once(server, 'listening'); +const {port} = server.address(); const origin = `http://${SITE_HOSTNAME}:${port}`; -const server = spawn('php', ['-S', `127.0.0.1:${port}`, '-t', siteDirectory], {stdio: ['ignore', 'pipe', 'pipe']}); let browser; try { - await waitForServer(port); const launchOptions = { headless: true, args: [`--host-resolver-rules=MAP ${SITE_HOSTNAME} 127.0.0.1`, '--no-proxy-server'], @@ -186,15 +256,22 @@ try { ['mobile', {width: 390, height: 844}], ['short height', {width: 640, height: 360}], ]; - for (const surface of surfaces) { - for (const [viewportName, viewport] of viewports) { - await validateSurface(browser, origin, surface, viewportName, viewport); + // Async-generator yields keep one context at a time on shared hosts. + async function* checks() { + for (const surface of surfaces) { + for (const [viewportName, viewport] of viewports) { + for (const theme of oneIdentity ? ['light', 'dark'] : ['legacy']) { + yield validateSurface(browser, origin, surface, viewportName, viewport, theme); + } + } } } + const completed = new Set(); + for await (const label of checks()) completed.add(label); - console.log('Validated seven portal/API surfaces at desktop, intermediate, mobile, and short-height viewports.'); + console.log(`Validated ${completed.size} portal/API theme/viewport cells; keyboard switcher, exact code copying and light-default controls passed.`); } finally { await browser?.close(); - server.kill('SIGTERM'); - await once(server, 'exit').catch(() => {}); + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); } diff --git a/scripts/check-docs-identity-status.test.mjs b/scripts/check-docs-identity-status.test.mjs new file mode 100644 index 00000000..fa01b827 --- /dev/null +++ b/scripts/check-docs-identity-status.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {publicStatus, fetchPublicStatus, watchPublicStatus, STATUS_API} from '../docs/portal/assets/identity/status.mjs'; + +const now = Date.parse('2026-11-05T12:00:30Z'); +function snapshot() { + return { + schema: 'durable_workflow.status_page_snapshot.v1', product: 'cloud', environment: 'prod', + checked_at: '2026-11-05T12:00:00Z', generated_at: '2026-11-05T12:00:05Z', + status: 'ok', alert_level: 'ok', customer_visible: false, + freshness: {status: 'fresh', age_seconds: 30, stale_after_seconds: 900}, + components: [], customer_impact: {status: 'ok', alert_level: 'ok', affected_families: []}, + }; +} +const state = s => publicStatus(s, now).state; +test('fresh public aggregate is healthy with no customer-visible incident rows', () => { + assert.equal(state(snapshot()), 'operational'); + assert.equal(publicStatus(snapshot(), now).validUntil, Date.parse('2026-11-05T12:15:00Z')); +}); +test('public impact cannot be hidden by healthy top-level fields', () => { + for (const [status, level, expected] of [['failed','critical','incident'],['warning','warning','degraded'],['unknown','unknown','unknown']]) { + const s = snapshot(); s.customer_impact.status = status; s.customer_impact.alert_level = level; + assert.equal(state(s), expected); + } +}); +test('severity tokens and maintenance-only affected family follow producer precedence', () => { + const s = snapshot(); s.status = 'warning'; s.alert_level = 'warning'; + assert.equal(state(s), 'degraded'); + s.customer_impact.affected_families = ['maintenance']; + assert.equal(state(s), 'maintenance'); + s.customer_impact.affected_families.push('managed_runtime'); + assert.equal(state(s), 'degraded'); + s.alert_level = 'critical'; assert.equal(state(s), 'incident'); +}); +test('public failure reasons retain unavailable, unverified and stale distinctions', () => { + for (const [reason, expected] of [['cloud_unreachable','unavailable'],['monitor_stale','unverified'],['probe_unverified','unverified'],['publisher_stale','stale'],['new_reason','unknown']]) { + const s = snapshot(); s.meta = {public_status_reason: reason}; assert.equal(state(s), expected); + } +}); +test('freshness is recomputed from checked time, including exact cutoff', () => { + const s = snapshot(); + assert.equal(publicStatus(s, Date.parse('2026-11-05T12:14:59Z')).state, 'operational'); + assert.equal(publicStatus(s, Date.parse('2026-11-05T12:15:00Z')).state, 'stale'); + s.freshness.age_seconds = 900; assert.equal(state(s), 'stale'); + s.freshness.age_seconds = 30; s.freshness.status = 'stale'; assert.equal(state(s), 'stale'); +}); +test('future, malformed, invalid and out-of-order evidence cannot show healthy', () => { + const mutations = [ + s => s.checked_at = '2026-11-05T12:01:00Z', + s => s.generated_at = '2026-11-05T12:01:00Z', + s => s.generated_at = '2026-11-05T11:59:00Z', + s => s.checked_at = '2026-99-99T12:00:00Z', + s => s.checked_at = '2026-11-05', + s => s.freshness.age_seconds = -1, + s => s.freshness.stale_after_seconds = 86400, + s => s.freshness.status = 'unrecognized', + s => s.schema = 'unknown', s => s.product = 'server', s => s.environment = 'test', + s => s.customer_impact = undefined, s => s.status = 'healthy', s => s.alert_level = 'healthy', + ]; + for (const mutate of mutations) {const s = snapshot(); mutate(s); assert.equal(state(s), 'unknown');} +}); +test('private/internal rows are ignored and never returned', () => { + const s = snapshot(); s.components = [{status: 'failed', message: 'private'}]; s.operator_alerts = ['private']; + const result = publicStatus(s, now); + assert.deepEqual(Object.keys(result).sort(), ['state','validUntil']); + assert.equal(result.state, 'operational'); +}); +test('anonymous feed uses exact public GET contract', async () => { + let request; + const result = await fetchPublicStatus({now: () => now, fetchImpl: async (url, options) => { + request = {url, options}; return {ok: true, text: async () => JSON.stringify(snapshot())}; + }}); + assert.equal(result.state, 'operational'); assert.equal(request.url, STATUS_API); + for (const [k,v] of Object.entries({method:'GET',mode:'cors',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer'})) assert.equal(request.options[k], v); +}); +test('HTTP, oversized and malformed responses refuse', async () => { + for (const response of [{ok:false},{ok:true,text:async()=> 'x'.repeat(65537)},{ok:true,text:async()=> '{'}]) { + await assert.rejects(fetchPublicStatus({fetchImpl: async () => response})); + } +}); +test('network failure publishes unknown and unmount aborts pending work', async () => { + const values = []; let signal; + const stop = watchPublicStatus(value => values.push(value), {fetchImpl: async (_, options) => {signal = options.signal; throw new Error('offline');}}); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(values, ['unknown']); stop(); assert.equal(signal.aborted, true); + const pending = []; + const stopPending = watchPublicStatus(value => pending.push(value), {fetchImpl: (_, options) => new Promise((_, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted'))))}); + stopPending(); await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(pending, []); +}); +test('a successful result expires while a later request is still pending', async t => { + t.mock.timers.enable({apis: ['setTimeout', 'Date'], now}); + const values = []; let requests = 0; + const s = snapshot(); s.freshness.stale_after_seconds = 90; + const stop = watchPublicStatus(value => values.push(value), {fetchImpl: async (_, options) => { + requests++; + if (requests === 1) return {ok:true, text:async()=>JSON.stringify(s)}; + return new Promise((_, reject) => options.signal.addEventListener('abort', () => reject(new Error('timeout')))); + }}); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(values, ['operational']); + t.mock.timers.tick(60000); + assert.equal(values.at(-1), 'stale'); + assert.equal(requests, 2); + t.mock.timers.tick(5000); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(values.at(-1), 'unknown'); + stop(); +}); +test('calendar overflow and 24-hour normalization cannot create fresh evidence', () => { + for (const [bad, normalized] of [['2026-02-30T12:00:00Z','2026-03-02T12:00:00Z'],['2026-11-04T24:00:00Z','2026-11-05T00:00:00Z']]) { + const s = snapshot(); const normalizedMs = Date.parse(normalized); + s.checked_at = bad; s.generated_at = new Date(normalizedMs + 5000).toISOString(); + assert.equal(publicStatus(s, normalizedMs + 30000).state, 'unknown'); + } +}); +test('valid offsets and subsecond timestamps preserve the same checked instant', () => { + const s = snapshot(); s.checked_at = '2026-11-05T14:00:00.000000+02:00'; s.generated_at = '2026-11-05T07:00:05-05:00'; + assert.equal(state(s), 'operational'); + assert.equal(publicStatus(s, now).validUntil, Date.parse('2026-11-05T12:15:00Z')); +}); +test('Z, positive-zero and unknown-local-offset forms identify the same UTC instant', () => { + for (const offset of ['Z','+00:00','-00:00']) { + const s = snapshot(); s.checked_at = '2026-11-05T12:00:00'+offset; + assert.equal(state(s), 'operational'); + assert.equal(publicStatus(s, now).validUntil, Date.parse('2026-11-05T12:15:00Z')); + } +}); diff --git a/scripts/finalize-api-reference.php b/scripts/finalize-api-reference.php index b9c64fce..26bff0a6 100644 --- a/scripts/finalize-api-reference.php +++ b/scripts/finalize-api-reference.php @@ -54,7 +54,8 @@ function removeIncompleteGraphPages(string $buildDirectory): int $description = 'Generated classes, methods, models, exceptions, and worker types for the Durable Workflow PHP SDK.'; $count = 0; $sourceDirectory = getenv('DOCS_SOURCE_DIRECTORY') ?: __DIR__.'/..'; -$faviconSource = $sourceDirectory.'/docs/portal/assets/favicon.svg'; +$portalDirectory = getenv('DOCS_PORTAL_DIRECTORY') ?: $sourceDirectory.'/docs/portal'; +$faviconSource = $portalDirectory.'/assets/favicon.svg'; $faviconTarget = $buildDirectory.'/assets/favicon.svg'; if (! is_file($faviconSource) || ! is_dir(dirname($faviconTarget)) || ! copy($faviconSource, $faviconTarget)) { diff --git a/scripts/prepare-docs-presentation.py b/scripts/prepare-docs-presentation.py new file mode 100644 index 00000000..11fcf224 --- /dev/null +++ b/scripts/prepare-docs-presentation.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Stage accepted presentation files over published docs without changing SDK code.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import subprocess +import sys + +REVISION = re.compile(r"[0-9a-f]{40}") +PRESENTATION_FILES = frozenset({ + "docs/portal/index.md", "docs/portal/_includes/layout.njk", + ".phpdoc/template/layout.html.twig", + ".phpdoc/template/components/identity-header.html.twig", + ".phpdoc/template/components/identity-footer.html.twig", +}) +PACKET_FILE = "presentation.json" +PRESENTATION_TREES = ("docs/portal/assets/", ".phpdoc/template/assets/") + + +class PresentationError(ValueError): + """The proposed presentation cannot be bound to accepted source.""" + + +def git(root: Path, *args: str) -> bytes: + return subprocess.check_output(["git", "-C", str(root), *args], timeout=30, stderr=subprocess.PIPE) + + +def copy_regular_tree(source: Path, target: Path) -> None: + if source.is_symlink(): + raise PresentationError("Documentation input roots must be real directories.") + for path in source.rglob("*"): + if path.is_symlink() or not (path.is_dir() or path.is_file()): + raise PresentationError("Documentation inputs must be regular files or directories.") + shutil.copytree(source, target) + + +def presentation_blobs(root: Path, revision: str) -> list[tuple[str, bytes]]: + entries = git(root, "ls-tree", "-rz", revision).split(b"\0") + overlays = [] + total = 0 + for entry in filter(None, entries): + metadata, name_bytes = entry.split(b"\t", 1) + name = name_bytes.decode("utf-8") + if name not in PRESENTATION_FILES and not name.startswith(PRESENTATION_TREES): + continue + mode, kind, blob = metadata.split(b" ") + if mode != b"100644" or kind != b"blob" or ".." in PurePosixPath(name).parts: + raise PresentationError("Presentation files must be regular non-executable blobs.") + content = git(root, "cat-file", "blob", blob.decode("ascii")) + total += len(content) + if len(content) > 5_242_880 or total > 20_971_520: + raise PresentationError("The presentation exceeds its source-only size bound.") + overlays.append((name, content)) + if not PRESENTATION_FILES.issubset({name for name, _ in overlays}): + raise PresentationError("The selected presentation is incomplete.") + return overlays + + +def prepare(root: Path, revision: str, destination: Path, release_adapter: Path) -> dict: + if not REVISION.fullmatch(revision): + raise PresentationError("presentation_source must be an exact lowercase commit.") + if git(root, "rev-parse", "--verify", revision + "^{commit}").decode().strip() != revision: + raise PresentationError("The presentation commit differs.") + if subprocess.run(["git", "-C", str(root), "merge-base", "--is-ancestor", revision, "origin/main"], + check=False, timeout=30, capture_output=True).returncode: + raise PresentationError("The presentation must come from accepted main.") + if git(root, "diff", "--name-only", "HEAD"): + raise PresentationError("The selected SDK source must remain unchanged.") + selected = git(root, "rev-parse", "HEAD").decode().strip() + if destination.exists(): + raise PresentationError("The presentation staging directory must not exist.") + overlays = presentation_blobs(root, revision) + destination.mkdir(parents=True) + copy_regular_tree(root / "docs/portal", destination / "portal") + copy_regular_tree(root / ".phpdoc/template", destination / "template") + for name, content in overlays: + if name.startswith("docs/portal/"): + path = destination / "portal" / name.removeprefix("docs/portal/") + else: + path = destination / "template" / name.removeprefix(".phpdoc/template/") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + # This workflow-owned adapter reads release facts from DOCS_SOURCE_DIRECTORY. + shutil.copyfile(release_adapter, destination / "portal/_data/release.js") + inventory = {path.relative_to(destination).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(destination.rglob("*")) if path.is_file()} + packet = {"schema": "durable-workflow.sdk-php.docs-presentation/v1", + "sdk_source_revision": selected, "presentation_source_revision": revision, "files": inventory} + (destination / PACKET_FILE).write_text(json.dumps(packet, sort_keys=True) + "\n") + return packet + + +def verify(destination: Path, selected: str, revision: str) -> None: + packet = json.loads((destination / PACKET_FILE).read_text()) + actual = {path.relative_to(destination).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(destination.rglob("*")) if path.is_file() and path != destination / PACKET_FILE} + if packet.get("sdk_source_revision") != selected or packet.get("presentation_source_revision") != revision \ + or packet.get("files") != actual or any(path.is_symlink() for path in destination.rglob("*")): + raise PresentationError("The staged presentation changed before artifact binding.") + + +def main() -> int: + try: + if len(sys.argv) != 3: + raise PresentationError("Expected an exact presentation commit and a fresh staging directory.") + root = Path.cwd() + destination = Path(sys.argv[2]).resolve() + adapter = Path(__file__).resolve().parent.parent / "docs/portal/_data/release.js" + packet = prepare(root, sys.argv[1], destination, adapter) + values = {"DOCS_PORTAL_DIRECTORY": str(destination / "portal"), + "DOCS_TEMPLATE_DIRECTORY": str(destination / "template"), + "DOCS_PRESENTATION_DIRECTORY": str(destination), + "DOCS_PRESENTATION_REVISION": packet["presentation_source_revision"]} + if any("\n" in value or "\r" in value for value in values.values()): + raise PresentationError("Staging paths must be single-line values.") + if os.environ.get("GITHUB_ENV"): + with Path(os.environ["GITHUB_ENV"]).open("a") as stream: + stream.write("".join(f"{key}={value}\n" for key, value in values.items())) + print(json.dumps({key: packet[key] for key in ("sdk_source_revision", "presentation_source_revision")})) + return 0 + except (OSError, ValueError, subprocess.SubprocessError): + print("Documentation presentation refused; no SDK source override is allowed.", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/prepare-docs-presentation.test.py b/scripts/prepare-docs-presentation.test.py new file mode 100644 index 00000000..b9608f35 --- /dev/null +++ b/scripts/prepare-docs-presentation.test.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Presentation staging must preserve published SDK and guide authority.""" +import importlib.util +import json +import os +import sys +from pathlib import Path +import subprocess +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('presentation', Path(__file__).with_name('prepare-docs-presentation.py')) +presentation = importlib.util.module_from_spec(spec) +spec.loader.exec_module(presentation) + + +class PresentationTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) / 'repo' + self.root.mkdir() + self.git('init', '-q') + self.git('config', 'user.email', 'fixture@example.invalid') + self.git('config', 'user.name', 'Source fixture') + for name, value in { + 'composer.json': '{"published_version":"3.9.4"}', 'src/Client.php': 'published implementation', + 'docs/quickstart-contract.json': '{"published_version":"3.9.4"}', + 'docs/portal/guide.md': 'published support and examples', + 'docs/portal/_data/release.js': 'original data loader', + '.phpdoc/template/index.html.twig': 'published API index', + }.items(): self.write(name, value) + for name in presentation.PRESENTATION_FILES: self.write(name, 'old presentation') + self.write('docs/portal/assets/identity.css', 'old palette') + self.git('add', '.') + self.git('commit', '-qm', 'published SDK') + self.sdk = self.git('rev-parse', 'HEAD') + self.write('src/Client.php', 'unpublished future API') + self.write('composer.json', '{"published_version":"3.10.0"}') + self.write('docs/quickstart-contract.json', '{"published_version":"3.10.0"}') + self.write('docs/portal/guide.md', 'unpublished future support floor') + self.write('.phpdoc/template/index.html.twig', 'future static API index') + self.write('docs/portal/assets/identity.css', 'new shared palette') + self.write('docs/portal/index.md', 'new landing page with release variables') + self.git('add', '.') + self.git('commit', '-qm', 'future SDK and shared presentation') + self.design = self.git('rev-parse', 'HEAD') + self.git('update-ref', 'refs/remotes/origin/main', self.design) + self.git('checkout', '-q', '--detach', self.sdk) + self.adapter = Path(self.temporary.name) / 'release.js' + self.adapter.write_text('read release facts from the selected SDK directory') + self.output = Path(self.temporary.name) / 'presentation' + + def git(self, *args): + return subprocess.check_output(['git', '-C', str(self.root), *args], stderr=subprocess.PIPE).decode().strip() + + def write(self, name, value): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value) + + def prepare(self): + return presentation.prepare(self.root, self.design, self.output, self.adapter) + + def testOnlyPresentationCanChangeAndBothExactSourcesAreRecorded(self): + packet = self.prepare() + self.assertEqual(packet['sdk_source_revision'], self.sdk) + self.assertEqual(packet['presentation_source_revision'], self.design) + self.assertEqual((self.output / 'portal/assets/identity.css').read_text(), 'new shared palette') + self.assertEqual((self.output / 'portal/guide.md').read_text(), 'published support and examples') + self.assertEqual((self.output / 'template/index.html.twig').read_text(), 'published API index') + self.assertEqual((self.root / 'src/Client.php').read_text(), 'published implementation') + self.assertEqual(json.loads((self.root / 'composer.json').read_text())['published_version'], '3.9.4') + self.assertEqual(self.git('status', '--porcelain'), '') + presentation.verify(self.output, self.sdk, self.design) + + def testArtifactBindsPublishedPackageAndPresentationSeparately(self): + self.prepare() + self.git('tag', '3.9.4', self.sdk) + (self.root / 'build/site').mkdir(parents=True) + environment = os.environ | {'DOCS_PRESENTATION_DIRECTORY': str(self.output), + 'DOCS_PRESENTATION_REVISION': self.design, 'STABLE_TAG': '3.9.4'} + binder = Path(__file__).with_name('bind-docs-artifact.py') + result = subprocess.run([sys.executable, str(binder), self.sdk], cwd=self.root, + env=environment, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + audit = json.loads((self.root / 'build/site/deployment-audit.json').read_text()) + self.assertEqual(audit['source_revision'], self.sdk) + self.assertEqual(audit['published_sdk_source_revision'], self.sdk) + self.assertEqual(audit['published_sdk_version'], '3.9.4') + self.assertEqual(audit['presentation_source_revision'], self.design) + (self.output / 'portal/assets/identity.css').write_text('changed after build') + result = subprocess.run([sys.executable, str(binder), self.sdk], cwd=self.root, + env=environment, capture_output=True) + self.assertNotEqual(result.returncode, 0) + + def testAnUnacceptedPresentationRefuses(self): + self.git('update-ref', 'refs/remotes/origin/main', self.sdk) + with self.assertRaises(presentation.PresentationError): self.prepare() + self.assertFalse(self.output.exists()) + + def testMutableOrWrongReferencesRefuse(self): + for revision in ['main', self.design.upper(), self.design[:12], '0' * 40]: + with self.subTest(revision=revision), self.assertRaises((presentation.PresentationError, subprocess.CalledProcessError)): + presentation.prepare(self.root, revision, self.output, self.adapter) + + def testChangedPublishedSourceRefuses(self): + self.write('src/Client.php', 'changed source') + with self.assertRaises(presentation.PresentationError): self.prepare() + + def testChangedOrAddedStagedFilesRefuseAtArtifactBinding(self): + self.prepare() + (self.output / 'portal/guide.md').write_text('future support') + with self.assertRaises(presentation.PresentationError): presentation.verify(self.output, self.sdk, self.design) + + def testSymlinkedPresentationRefuses(self): + self.git('checkout', '-q', '--detach', self.design) + path = self.root / 'docs/portal/assets/identity.css' + path.unlink() + path.symlink_to('../../guide.md') + self.git('add', '.') + self.git('commit', '-qm', 'symlink refusal fixture') + self.design = self.git('rev-parse', 'HEAD') + self.git('update-ref', 'refs/remotes/origin/main', self.design) + self.git('checkout', '-q', '--detach', self.sdk) + with self.assertRaises(presentation.PresentationError): self.prepare() + self.assertFalse(self.output.exists()) + + +if __name__ == '__main__': unittest.main() diff --git a/scripts/test-docs-release-channel.py b/scripts/test-docs-release-channel.py index a2ade3f6..faa89944 100644 --- a/scripts/test-docs-release-channel.py +++ b/scripts/test-docs-release-channel.py @@ -136,7 +136,8 @@ def test_artifact_audit_uses_exact_selected_source(self) -> None: (self.root / "build/site").mkdir(parents=True, exist_ok=True) result = subprocess.run([sys.executable, str(BIND), source], cwd=self.root, env=self.environment, capture_output=True) self.assertEqual(0, result.returncode, result.stderr) - self.assertEqual({"schema": "durable-workflow.sdk-php.docs-deployment/v1", "source_revision": source}, + self.assertEqual({"schema": "durable-workflow.sdk-php.docs-deployment/v2", "source_revision": source, + "sdk_source_revision": source, "presentation_source_revision": source}, json.loads((self.root / "build/site/deployment-audit.json").read_text())) def test_artifact_refuses_wrong_identity_and_modified_selected_source(self) -> None: