diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml new file mode 100644 index 00000000..f70d52cf --- /dev/null +++ b/.github/workflows/website.yml @@ -0,0 +1,78 @@ +name: Website + +on: + push: + branches: [main] + paths: + - 'website/**' + - '.github/workflows/website.yml' + pull_request: + paths: + - 'website/**' + - '.github/workflows/website.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test-and-build: + runs-on: ubuntu-latest + timeout-minutes: 5 + defaults: + run: + working-directory: website + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # Pinned to the existing repository workflows + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '22' + package-manager-cache: false + - name: Install Hugo v0.167.0 + env: + HUGO_VERSION: '0.167.0' + HUGO_SHA256: '4d84519b9f619e6d4c3fb45a50157abeabeb724f859c60605f44c23def6e1169' + run: | + archive="hugo_${HUGO_VERSION}_linux-amd64.tar.gz" + directory="${RUNNER_TEMP}/hugo" + mkdir -p "$directory" + curl --fail --silent --show-error --location --retry 3 \ + "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/${archive}" \ + --output "${directory}/${archive}" + printf '%s %s\n' "$HUGO_SHA256" "${directory}/${archive}" | sha256sum --check --strict + tar --extract --gzip --file "${directory}/${archive}" --directory "$directory" hugo + echo "$directory" >> "$GITHUB_PATH" + "${directory}/hugo" version + - name: Test + run: npm test + - name: Build + run: npm run build + - name: Upload GitHub Pages artifact + if: github.repository == 'modelpack/modctl' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 + with: + path: website/dist + + deploy: + if: github.repository == 'modelpack/modctl' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + needs: test-and-build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pages: write + id-token: write + # Serialize deployments without interrupting a site that's being published. + concurrency: + group: github-pages + cancel-in-progress: false + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Configure GitHub Pages + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.gitignore b/.gitignore index 6210b861..0f9952da 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ Temporary Items # output bin output +website/dist/ +website/.hugo_build.lock +website/resources/ diff --git a/README.md b/README.md index 02e5aca3..d13a548a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,20 @@ It offers commands such as `build`, `pull`, `push`, and more, making it easy for You can find the full documentation on the [getting started](./docs/getting-started.md). +## Website + +Project website: . + +The project website lives in [`website/`](./website/README.md), built with Hugo's native multilingual support and separate English/Chinese content. +Install Hugo 0.167.0 and Node.js 22 or newer, then run locally without npm package dependencies: + +```shell +cd website +npm run dev +``` + +Open . Use `npm test` to validate the site and `npm run build` to create the deployable `website/dist/` directory. + ## Copyright Copyright © contributors to ModelPack, established as ModelPack a Series of LF Projects, LLC. diff --git a/website/README.md b/website/README.md new file mode 100644 index 00000000..15e851fe --- /dev/null +++ b/website/README.md @@ -0,0 +1,109 @@ +# modctl website + +The website uses **Hugo 0.167.0's native multilingual support** to generate static pages. GitHub Pages hosts only the build output. There is no client-side translation engine, custom template engine, or custom HTTP server. + +## Getting started + +Install the standard edition of [Hugo 0.167.0](https://github.com/gohugoio/hugo/releases/tag/v0.167.0) and add `hugo` to your PATH. The Extended edition is not required. Node.js 22+ is used only for npm commands and tests. There are no npm package dependencies, so `npm install` is unnecessary. + +```sh +cd website +hugo version +npm run dev +``` + +Hugo's built-in development server watches files and reloads pages when they change: + +- English: +- Chinese: +- English documentation: +- Chinese documentation: + +You can also run Hugo directly without npm: + +```sh +hugo server --bind localhost --port 4173 --baseURL http://localhost:4173/ +``` + +The server listens on localhost by default. Website tooling does not change the Go application's dependencies. + +## Directory structure + +```text +website/ +├── hugo.toml # Base URL, languages, and Markdown rendering +├── content/ +│ ├── en/ +│ │ ├── _index.md # English homepage content in front matter +│ │ └── getting-started.md # English documentation in Markdown +│ └── zh/ # Corresponding Chinese content +├── i18n/ +│ ├── en.json # English UI, accessibility, and feedback messages +│ └── zh.json # Chinese translations with the same keys +├── data/commands.json # CLI examples shared across languages +├── layouts/ +│ ├── baseof.html # HTML shell, language, and SEO metadata +│ ├── home.html # Shared homepage layout, without language branches +│ ├── docs/single.html # Shared documentation layout and table of contents +│ ├── _markup/ # Markdown code-block render hook with copy controls +│ └── partials/ # Shared navigation, footer, workflows, and diagrams +├── assets/artifact.svg # SVG template localized at build time +├── static/ # CSS, interaction scripts, fonts, and shared assets +└── scripts/site.test.mjs # Tests using real Hugo builds and generated output +``` + +### Maintaining multilingual content + +- Keep page content in each language's `content` directory and short UI messages in `i18n`. Editing text should not require changes to JavaScript or the HTML layout. +- Use matching relative filenames and `translationKey` values for corresponding pages. Hugo associates the translations natively. Homepage front matter has the same structure in each language, with translated values. +- Hugo's `i18n` function renders UI messages into HTML at build time. Menu and copy controls use the labels already rendered for the current page. They do not detect a language or replace page content in the browser. +- Language navigation uses `.Translations` to generate ordinary links to the current page's translations. Content, code examples, and language links remain available with JavaScript disabled. +- The URL determines the language. English is served at the root and Chinese at `/zh/`. The site does not use browser-language redirects, cookies, or localStorage. The prototype's `?lang=zh` parameter no longer selects a language. Use `/zh/` instead. +- HTML `lang`, canonical URLs, `hreflang` links, and sitemaps are generated at build time. To add a language, update `hugo.toml`, add corresponding `content//` and `i18n/.json` files, and extend the current bilingual acceptance tests. +- Keep CLI examples consistent with the repository's `docs/getting-started.md`. Illustrations and fonts are served locally. Font licenses are included in `static/assets/*-LICENSE.txt`. + +### JavaScript responsibilities + +`static/app.js` handles only the mobile menu, terminal tabs, clipboard controls, and table-of-contents highlighting. All localized page and terminal content is generated at build time. With JavaScript disabled, all workflow examples remain visible and inactive copy buttons are hidden. + +## Testing and building + +```sh +npm test +npm run build +``` + +Build output is written to `dist/`, including separate language pages, shared assets, and sitemaps. Hosting the generated files requires neither Node.js nor Hugo nor a backend service. Use the same Hugo version as CI, which verifies the official release archive against a pinned SHA-256 checksum. + +Tests use Node's built-in test runner and invoke Hugo directly rather than maintaining a separate rendering or translation implementation. They check: + +- Matching translation keys, nonempty messages, corresponding content files, and front matter structure across languages. +- Build failures for missing translations and other warnings instead of silently hiding missing translations through fallback behavior. +- Generated files, resource links and anchors under `/modctl/`, canonical URLs, and reciprocal `hreflang` links. +- Copy buttons generated from Markdown code blocks and their corresponding code element IDs. +- The absence of legacy `data-zh` attributes, client-side language-switching functions, and language storage. + +If Hugo is not on your PATH, set `HUGO_BINARY=/absolute/path/to/hugo` when running tests. Development and build commands invoke `hugo` directly, without a custom installer or wrapper. + +Browser regression checks should cover both pages in both languages, viewport widths from 320px to 1920px, JavaScript-disabled access, language navigation, copying, keyboard tab controls, mobile navigation, and reduced motion. Automated accessibility checks do not replace visual and maintainability reviews. + +## GitHub Pages + +The configured site URL is . Chinese pages are served at . + +`.github/workflows/website.yml` tests and builds pull requests. It uploads `website/dist/` and deploys only when website or workflow changes reach `main` in the upstream `modelpack/modctl` repository, or when manually triggered on `main`. Forks do not deploy automatically. + +Initial setup: + +1. Select **GitHub Actions** in [Settings → Pages](https://github.com/modelpack/modctl/settings/pages). +2. Merge into `main` using the repository's existing review process. Do not bypass branch protection. +3. Check results in [Actions → Website](https://github.com/modelpack/modctl/actions/workflows/website.yml). To redeploy, select **Run workflow** on `main`. +4. The `github-pages` environment permits deployments only from `main`. If environment reviewers are configured, their approval is also required. + +The build job has read-only permissions. The deploy job's `pages: write` and `id-token: write` permissions are scoped to GitHub Pages deployment. No additional PAT, CNAME, or `gh-pages` branch is needed. Deployments are serialized without interrupting an in-progress deployment. + +Hugo's `baseURL` is `https://modelpack.github.io/modctl/`. Templates use `.RelPermalink` and `relURL` rather than hard-coding `/modctl/`. The development command overrides this with a local URL. To use another domain or subpath, update `baseURL` and the test target. + +## Design references + +The design uses a cool-white, ink-blue, and blue palette with layered model-artifact illustrations. Content organization draws on Cilium, Envoy, and Harbor. Multilingual project organization follows the [Kubernetes website](https://github.com/kubernetes/website/blob/main/hugo.toml) and [Hugo's multilingual documentation](https://gohugo.io/content-management/multilingual/). No external project's page code or endorsement is reused. diff --git a/website/assets/artifact.svg b/website/assets/artifact.svg new file mode 100644 index 00000000..864656a2 --- /dev/null +++ b/website/assets/artifact.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{{ i18n "artifactWeights" | htmlEscape }}{{ i18n "artifactConfig" | htmlEscape }}{{ i18n "artifactCode" | htmlEscape }}{{ i18n "artifactDocs" | htmlEscape }} + + diff --git a/website/content/en/_index.md b/website/content/en/_index.md new file mode 100644 index 00000000..2e928648 --- /dev/null +++ b/website/content/en/_index.md @@ -0,0 +1,62 @@ +--- +title: "Your models. Ready for anywhere." +description: "Build, push, and pull AI models as OCI artifacts. modctl brings a familiar, open-standard workflow to model distribution." +translationKey: home +hero: + eyebrow: "OPEN SOURCE. MODEL NATIVE." + lines: ["Your models.", "Ready for", "anywhere."] + description: "Ship AI models like you ship containers. Build, push, and pull OCI model artifacts with one simple, familiar command-line tool." +why: + eyebrow: "LESS FRICTION. MORE BUILDING." + title: "Model delivery." + subtitle: "Without the detours." + description: "Your models don't need a separate universe. Bring weights, configuration, and code together, then put your existing infrastructure to work." +features: + - visual: package + title: "Many files. One artifact." + description: "Package weights, config, code, and docs into a standard OCI model artifact with a declarative Modelfile." + link: "Meet the Modelfile" + anchor: modelfile + - visual: registry + title: "Your registry. Already ready." + description: "Push models to OCI-compatible registries. Keep the tags, authentication, and distribution workflows you already know." + link: "Explore distribution" + anchor: distribute + - visual: fetch + title: "Just what you need." + description: "Fetch files by pattern or extract models straight from a remote registry, without keeping an extra local artifact copy." + link: "Work a little lighter" + anchor: extract +workflow: + eyebrow: "FROM YOUR DIRECTORY TO YOUR REGISTRY" + title: "Same workflow." + subtitle: "New possibilities." + description: "Three familiar commands. One open path from local development to wherever your models go next." + note: "Not another platform. Just a better way to ship." + steps: + - id: build + title: "Build your model" + subtitle: "From model files to an OCI artifact" + comment: "Turn a model directory into an OCI artifact" + results: ["Weights, configuration, code & docs", "Model Spec artifact layout", "Ready for your OCI registry"] + - id: push + title: "Push to your registry" + subtitle: "Package once, distribute anywhere" + comment: "Authenticate, then publish your model" + results: ["Use your existing registry credentials", "Publish a versioned model artifact", "Share a reference, not a folder of files"] + - id: pull + title: "Pull it. Make it yours." + subtitle: "Get your model where it needs to be" + comment: "Bring the model to your workspace" + results: ["Retrieve a model by its registry reference", "Extract the model files to your directory", "Continue with your own inference tooling"] +ecosystem: + eyebrow: "PART OF YOUR STACK. NOT A NEW ONE." + title: "Built to fit." + subtitle: "Not to lock you in." + description: "Built on the ModelPack Model Spec, modctl packages models as OCI-compatible artifacts. Choose where your models live, without choosing a proprietary format." +community: + eyebrow: "OPEN SOURCE. SHARED FUTURE." + title: "Your next model." + subtitle: "A better way to ship it." + description: "Start with your first artifact. Or help shape what comes next." +--- diff --git a/website/content/en/getting-started.md b/website/content/en/getting-started.md new file mode 100644 index 00000000..5581e419 --- /dev/null +++ b/website/content/en/getting-started.md @@ -0,0 +1,138 @@ +--- +title: "Your first model artifact." +description: "Install modctl and build, push, and pull your first OCI model artifact." +type: docs +translationKey: getting-started +eyebrow: "DOCUMENTATION / QUICKSTART" +intro: "Go from a local model directory to an OCI artifact in your registry. This guide takes you through the complete workflow." +note: "You'll need a directory of model files and an OCI-compatible registry you can push to. The reference registry.com/models/llama3:v1.0.0 is a placeholder. Replace it with your own." +--- + +## 01. Install modctl {#installation} + +Installing from `main` requires Go 1.25.5 or newer, as declared in the current `go.mod`. Make sure your Go bin directory is on your `PATH`. This command tracks `main` rather than a pinned stable release. + +```bash +go install github.com/modelpack/modctl@main +modctl --help +``` + +You can also explore the project's [releases](https://github.com/modelpack/modctl/releases) for available versions. + +### Build from source + +```bash +git clone https://github.com/modelpack/modctl.git +cd modctl +make +./output/modctl -h +``` + +Building from source also requires Git and Make. Run `./output/modctl` directly, or place the resulting binary on your `PATH`. + +## 02. Define your model {#modelfile} + +Run the generator inside your model directory, then review and adjust the `Modelfile`. It describes model metadata and which weights, configuration, code, and documentation to include. + +```bash +modctl modelfile generate . +``` + +A minimal example for a Safetensors model. Adapt the metadata and file patterns to match your actual model and files. + +```text +NAME llama3 +ARCH transformer +FAMILY llama3 +FORMAT safetensors +PRECISION bf16 + +CONFIG config.json +MODEL *.safetensors +CODE *.py +DOC *.md +``` + +### Exclude files you don't need + +Use `--exclude` during generation to skip matching files. It supports basic glob patterns (`*`, `?`, `[]`) rather than recursive `**` patterns. + +```bash +modctl modelfile generate . --exclude 'checkpoint-*' +``` + +## 03. Build an OCI artifact {#build} + +Build the artifact from the same directory and give it a registry reference. By default, the artifact stays local. Building does not automatically upload your model. + +```bash +modctl build \ + -t registry.com/models/llama3:v1.0.0 \ + -f Modelfile . +``` + +### Build directly to a remote registry + +Log in to your target registry first, then use `--output-remote` to push directly as part of the build. This avoids keeping both the source model and a local built copy. + +```bash +modctl login -u YOUR_USERNAME registry.com +modctl build \ + -t registry.com/models/llama3:v1.0.0 \ + -f Modelfile . --output-remote +``` + +## 04. Push and pull {#distribute} + +Log in to your registry and enter your password or access token at the terminal prompt. Then push your locally built artifact. Avoid putting a real password directly in command-line arguments. + +```bash +modctl login -u YOUR_USERNAME registry.com +modctl push registry.com/models/llama3:v1.0.0 +``` + +Pull the artifact by the same reference on another machine or workspace. Authenticate first when using a private registry. + +```bash +modctl pull registry.com/models/llama3:v1.0.0 +``` + +## 05. Work with your model files {#extract} + +Extract the model files to a directory, then load them with your own inference tooling. modctl manages model artifacts. It does not provide an inference runtime. + +```bash +modctl extract registry.com/models/llama3:v1.0.0 \ + --output ./my-model +``` + +### Extract directly from a registry + +Extract a remote model directly to your target directory without keeping a full local artifact copy. + +```bash +modctl pull registry.com/models/llama3:v1.0.0 \ + --extract-dir ./my-model --extract-from-remote +``` + +### Fetch only matching files + +Only need configuration files? Use a file pattern to select what to fetch. + +```bash +modctl fetch registry.com/models/llama3:v1.0.0 \ + --output ./model-config --patterns '*.json' +``` + +## Keep building {#next} + +List local artifacts, inspect their metadata, or give an existing model a new tag. + +```bash +modctl ls +modctl inspect registry.com/models/llama3:v1.0.0 +modctl tag registry.com/models/llama3:v1.0.0 \ + registry.com/models/llama3:latest +``` + +Read the [full command guide](https://github.com/modelpack/modctl/blob/main/docs/getting-started.md) for more capabilities, including `attach` and `upload`. Issues and contributions are welcome on GitHub. diff --git a/website/content/zh/_index.md b/website/content/zh/_index.md new file mode 100644 index 00000000..9e22730d --- /dev/null +++ b/website/content/zh/_index.md @@ -0,0 +1,62 @@ +--- +title: "你的模型,自由交付。" +description: "使用 modctl 构建、推送和拉取 OCI 模型制品。开放标准,熟悉流程,让模型交付回归简单。" +translationKey: home +hero: + eyebrow: "开源开放 · 为 AI 模型而生" + lines: ["你的模型,", "随时就绪,", "自由交付。"] + description: "像管理容器一样管理 AI 模型。用 modctl 构建、推送和拉取 OCI 模型制品,让模型交付回归简单。" +why: + eyebrow: "少一点繁琐,多一点创造" + title: "交付模型," + subtitle: "无需绕路。" + description: "模型不必拥有一套孤立的交付系统。modctl 将模型权重、配置和代码打包,让它们融入你熟悉的基础设施。" +features: + - visual: package + title: "多个文件,一个制品。" + description: "通过声明式 Modelfile,将权重、配置、代码和文档构建为标准的 OCI 模型制品。" + link: "认识 Modelfile" + anchor: modelfile + - visual: registry + title: "现有仓库,即刻复用。" + description: "将模型推送到兼容 OCI 的仓库,沿用熟悉的版本标签、认证方式和分发流程。" + link: "探索模型分发" + anchor: distribute + - visual: fetch + title: "精准获取,轻装上阵。" + description: "按文件模式获取所需内容,或直接从远端提取模型,减少不必要的本地存储。" + link: "更灵活地使用模型" + anchor: extract +workflow: + eyebrow: "从本地目录到模型仓库" + title: "熟悉的流程," + subtitle: "全新的可能。" + description: "三个熟悉的命令。一条开放的交付路径。让模型从本地开发走向更多环境。" + note: "不是新平台,只是更简单的交付方式。" + steps: + - id: build + title: "构建模型" + subtitle: "从模型文件到 OCI 制品" + comment: "将模型目录打包为 OCI 制品" + results: ["权重、配置、代码与文档", "遵循 Model Spec 的制品结构", "准备好推送至你的仓库"] + - id: push + title: "推送至仓库" + subtitle: "一次打包,随处分发" + comment: "认证后,将模型发布至仓库" + results: ["沿用现有仓库凭据", "发布带有版本标签的模型制品", "分享一个引用,而非整个文件目录"] + - id: pull + title: "拉取并使用" + subtitle: "让模型在需要的地方就绪" + comment: "将模型拉取到你的工作区" + results: ["通过仓库引用获取模型", "将模型文件提取至指定目录", "接入你自己的推理工具"] +ecosystem: + eyebrow: "融入你的技术栈,而非取代它" + title: "为开放生态" + subtitle: "而构建。" + description: "modctl 基于 ModelPack Model Spec 构建,将模型打包成兼容 OCI 的制品。为你的模型选择仓库,而不是让仓库决定模型的未来。" +community: + eyebrow: "开放源码,共同构建" + title: "下一个模型," + subtitle: "换一种更好的交付方式。" + description: "从第一个制品开始,或者加入我们,共同改进模型交付。" +--- diff --git a/website/content/zh/getting-started.md b/website/content/zh/getting-started.md new file mode 100644 index 00000000..dd773d09 --- /dev/null +++ b/website/content/zh/getting-started.md @@ -0,0 +1,138 @@ +--- +title: "从你的第一个模型开始。" +description: "安装 modctl,构建、推送并拉取你的第一个 OCI 模型制品。" +type: docs +translationKey: getting-started +eyebrow: "文档 / 快速入门" +intro: "从本地模型目录出发,构建一个 OCI 制品,并将它推送至模型仓库。本指南带你走完完整流程。" +note: "准备好一个包含模型文件的目录,以及一个你有推送权限、兼容 OCI 的仓库。示例中的 registry.com/models/llama3:v1.0.0 是占位引用,请替换为你自己的地址。" +--- + +## 01. 安装 modctl {#installation} + +从 `main` 分支安装需要 Go 1.25.5 或更新版本(以当前 `go.mod` 为准)。请确保 Go 的 bin 目录位于 `PATH` 中。以下命令跟随 `main` 分支,并非固定的稳定发行版。 + +```bash +go install github.com/modelpack/modctl@main +modctl --help +``` + +也可以查看项目的[版本发布页面](https://github.com/modelpack/modctl/releases),了解可用版本。 + +### 从源码构建 + +```bash +git clone https://github.com/modelpack/modctl.git +cd modctl +make +./output/modctl -h +``` + +源码构建还需要 Git 和 Make。可直接使用 `./output/modctl`,或将生成的二进制文件加入 `PATH`。 + +## 02. 定义你的模型 {#modelfile} + +在模型所在目录运行生成命令,然后检查并按需调整 `Modelfile`。它描述模型元数据,以及应包含的权重、配置、代码和文档。 + +```bash +modctl modelfile generate . +``` + +一个 Safetensors 模型的精简示例。请根据实际文件和模型属性调整元数据及文件模式。 + +```text +NAME llama3 +ARCH transformer +FAMILY llama3 +FORMAT safetensors +PRECISION bf16 + +CONFIG config.json +MODEL *.safetensors +CODE *.py +DOC *.md +``` + +### 排除不需要的文件 + +生成时通过 `--exclude` 跳过匹配的文件。此选项支持基础通配符 `*`、`?`、`[]`,不支持递归匹配 `**`。 + +```bash +modctl modelfile generate . --exclude 'checkpoint-*' +``` + +## 03. 构建 OCI 制品 {#build} + +在同一目录构建制品,并为它指定仓库引用。默认构建将制品存放在本地,不会自动上传模型。 + +```bash +modctl build \ + -t registry.com/models/llama3:v1.0.0 \ + -f Modelfile . +``` + +### 直接构建至远端仓库 + +先登录目标仓库,再使用 `--output-remote`,在构建时将制品直接上传至远端。这样无需同时保留原始模型和本地构建副本。 + +```bash +modctl login -u YOUR_USERNAME registry.com +modctl build \ + -t registry.com/models/llama3:v1.0.0 \ + -f Modelfile . --output-remote +``` + +## 04. 推送与拉取 {#distribute} + +登录目标仓库,在终端提示时输入密码或访问令牌。然后推送本地构建的制品。请勿将真实密码直接写在命令行参数中。 + +```bash +modctl login -u YOUR_USERNAME registry.com +modctl push registry.com/models/llama3:v1.0.0 +``` + +在另一台机器或工作区,通过相同的引用拉取制品。私有仓库需要先完成认证。 + +```bash +modctl pull registry.com/models/llama3:v1.0.0 +``` + +## 05. 使用模型文件 {#extract} + +将制品中的模型文件提取到指定目录,再使用你自己的推理工具加载。modctl 负责模型制品管理,不提供推理运行时。 + +```bash +modctl extract registry.com/models/llama3:v1.0.0 \ + --output ./my-model +``` + +### 直接从远端提取 + +跳过完整本地制品副本,直接将远端模型提取至目标目录。 + +```bash +modctl pull registry.com/models/llama3:v1.0.0 \ + --extract-dir ./my-model --extract-from-remote +``` + +### 仅获取匹配的文件 + +只需要配置文件?使用文件模式选择需要获取的内容。 + +```bash +modctl fetch registry.com/models/llama3:v1.0.0 \ + --output ./model-config --patterns '*.json' +``` + +## 继续探索 {#next} + +查看本地制品及其元数据,或者为现有模型添加新的标签。 + +```bash +modctl ls +modctl inspect registry.com/models/llama3:v1.0.0 +modctl tag registry.com/models/llama3:v1.0.0 \ + registry.com/models/llama3:latest +``` + +查阅[完整命令指南](https://github.com/modelpack/modctl/blob/main/docs/getting-started.md),了解 `attach`、`upload` 等更多能力。也欢迎在 GitHub 提交问题或贡献代码。 diff --git a/website/data/commands.json b/website/data/commands.json new file mode 100644 index 00000000..82f27812 --- /dev/null +++ b/website/data/commands.json @@ -0,0 +1,6 @@ +{ + "install": "go install github.com/modelpack/modctl@main", + "build": "modctl modelfile generate .\n\nmodctl build \\\n -t registry.com/models/llama3:v1.0.0 \\\n -f Modelfile .", + "push": "modctl login -u YOUR_USERNAME registry.com\n\nmodctl push \\\n registry.com/models/llama3:v1.0.0", + "pull": "modctl pull \\\n registry.com/models/llama3:v1.0.0\n\nmodctl extract \\\n registry.com/models/llama3:v1.0.0 \\\n --output ./my-model" +} diff --git a/website/hugo.toml b/website/hugo.toml new file mode 100644 index 00000000..7c26d099 --- /dev/null +++ b/website/hugo.toml @@ -0,0 +1,38 @@ +baseURL = 'https://modelpack.github.io/modctl/' +title = 'modctl' +defaultContentLanguage = 'en' +defaultContentLanguageInSubdir = false +publishDir = 'dist' +disableKinds = ['taxonomy', 'term', 'RSS'] +enableRobotsTXT = true + +[languages.en] + locale = 'en' + label = 'English' + contentDir = 'content/en' + weight = 1 + +[languages.zh] + locale = 'zh-CN' + label = '中文' + contentDir = 'content/zh' + weight = 2 + +[params] + repository = 'https://github.com/modelpack/modctl' + specification = 'https://github.com/modelpack/model-spec' + organization = 'https://github.com/modelpack' + +[markup.goldmark.parser.attribute] + block = true + title = true + +[markup.goldmark.renderer] + unsafe = false + +[markup.highlight] + codeFences = true + +[markup.tableOfContents] + startLevel = 2 + endLevel = 2 diff --git a/website/i18n/en.json b/website/i18n/en.json new file mode 100644 index 00000000..ddfe763c --- /dev/null +++ b/website/i18n/en.json @@ -0,0 +1,59 @@ +{ + "skipContent": { "other": "Skip to content" }, + "home": { "other": "modctl home" }, + "mainNavigation": { "other": "Main navigation" }, + "openNavigation": { "other": "Open navigation" }, + "closeNavigation": { "other": "Close navigation" }, + "whyModctl": { "other": "Why modctl" }, + "workflow": { "other": "Workflow" }, + "ecosystem": { "other": "Ecosystem" }, + "documentation": { "other": "Documentation" }, + "getStarted": { "other": "Get started" }, + "startBuilding": { "other": "Start building" }, + "exploreGithub": { "other": "Explore on GitHub" }, + "githubLabel": { "other": "modctl on GitHub" }, + "languageNavigation": { "other": "Choose language" }, + "copyCommand": { "other": "Copy command" }, + "copyCommands": { "other": "Copy commands" }, + "copySuccess": { "other": "Command copied to clipboard" }, + "copyFailure": { "other": "Clipboard unavailable. Select the command and copy it manually." }, + "openSourceCLI": { "other": "Open-source CLI" }, + "builtOnModelpack": { "other": "Built on ModelPack" }, + "openStandards": { "other": "Open standards" }, + "standardsEyebrow": { "other": "BUILT ON COMMON GROUND" }, + "modelSpecAligned": { "other": "Model Spec aligned" }, + "ociArtifacts": { "other": "OCI-compatible artifacts" }, + "openCode": { "other": "Open code. Open possibilities." }, + "artifactEyebrow": { "other": "ANATOMY OF A MODEL ARTIFACT" }, + "artifactDescription": { "other": "Model weights, configuration, and code packaged into one portable OCI artifact" }, + "artifactWeights": { "other": "MODEL WEIGHTS" }, + "artifactConfig": { "other": "CONFIG" }, + "artifactCode": { "other": "CODE" }, + "artifactDocs": { "other": "DOCUMENTATION" }, + "oneArtifact": { "other": "One artifact." }, + "everyPossibility": { "other": "Every possibility." }, + "workflowNavigation": { "other": "Model workflow" }, + "workflowExample": { "other": "WORKFLOW EXAMPLE" }, + "modelFiles": { "other": "YOUR MODEL FILES" }, + "infrastructure": { "other": "YOUR INFRASTRUCTURE" }, + "configCode": { "other": "Config + code" }, + "registries": { "other": "OCI registries" }, + "registryCaption": { "other": "Open format. Familiar tooling." }, + "ecosystemDescription": { "other": "Model files flow through modctl to OCI-compatible registries" }, + "exploreSpec": { "other": "Explore the Model Spec" }, + "getStartedModctl": { "other": "Get started with modctl" }, + "contribute": { "other": "Contribute on GitHub" }, + "footerTagline": { "other": "Model delivery, made simple." }, + "project": { "other": "PROJECT" }, + "resources": { "other": "RESOURCES" }, + "releases": { "other": "Releases" }, + "gettingStarted": { "other": "Getting started" }, + "feedback": { "other": "Issues & feedback" }, + "copyright": { "other": "ModelPack contributors. Apache 2.0 licensed." }, + "modelpackProject": { "other": "A ModelPack project" }, + "onThisPage": { "other": "On this page" }, + "sourceDocs": { "other": "View source documentation" }, + "backHome": { "other": "Back to modctl" }, + "improveDocs": { "other": "Suggest an improvement" }, + "codeBlock": { "other": "Code example" } +} diff --git a/website/i18n/zh.json b/website/i18n/zh.json new file mode 100644 index 00000000..c619aa61 --- /dev/null +++ b/website/i18n/zh.json @@ -0,0 +1,59 @@ +{ + "skipContent": { "other": "跳转到主要内容" }, + "home": { "other": "modctl 首页" }, + "mainNavigation": { "other": "主导航" }, + "openNavigation": { "other": "打开导航" }, + "closeNavigation": { "other": "关闭导航" }, + "whyModctl": { "other": "为什么选择 modctl" }, + "workflow": { "other": "工作流程" }, + "ecosystem": { "other": "开放生态" }, + "documentation": { "other": "文档" }, + "getStarted": { "other": "快速开始" }, + "startBuilding": { "other": "开始构建" }, + "exploreGithub": { "other": "在 GitHub 上探索" }, + "githubLabel": { "other": "在 GitHub 上查看 modctl" }, + "languageNavigation": { "other": "选择语言" }, + "copyCommand": { "other": "复制命令" }, + "copyCommands": { "other": "复制命令" }, + "copySuccess": { "other": "命令已复制到剪贴板" }, + "copyFailure": { "other": "浏览器不允许复制,请选中命令手动复制。" }, + "openSourceCLI": { "other": "开源 CLI" }, + "builtOnModelpack": { "other": "基于 ModelPack" }, + "openStandards": { "other": "开放标准" }, + "standardsEyebrow": { "other": "基于开放标准构建" }, + "modelSpecAligned": { "other": "遵循 Model Spec" }, + "ociArtifacts": { "other": "兼容 OCI 制品" }, + "openCode": { "other": "开放的代码,开放的可能" }, + "artifactEyebrow": { "other": "模型制品的组成" }, + "artifactDescription": { "other": "模型权重、配置、代码和文档打包成一个可移植的 OCI 制品" }, + "artifactWeights": { "other": "模型权重" }, + "artifactConfig": { "other": "配置" }, + "artifactCode": { "other": "代码" }, + "artifactDocs": { "other": "文档" }, + "oneArtifact": { "other": "一个制品。" }, + "everyPossibility": { "other": "无限可能。" }, + "workflowNavigation": { "other": "模型工作流程" }, + "workflowExample": { "other": "命令示例" }, + "modelFiles": { "other": "你的模型文件" }, + "infrastructure": { "other": "你的基础设施" }, + "configCode": { "other": "配置 + 代码" }, + "registries": { "other": "OCI 仓库" }, + "registryCaption": { "other": "开放格式,熟悉的工具。" }, + "ecosystemDescription": { "other": "模型文件通过 modctl 打包并分发至兼容 OCI 的仓库" }, + "exploreSpec": { "other": "了解 Model Spec" }, + "getStartedModctl": { "other": "开始使用 modctl" }, + "contribute": { "other": "参与贡献" }, + "footerTagline": { "other": "模型交付,回归简单。" }, + "project": { "other": "项目" }, + "resources": { "other": "资源" }, + "releases": { "other": "版本发布" }, + "gettingStarted": { "other": "快速入门" }, + "feedback": { "other": "问题与反馈" }, + "copyright": { "other": "ModelPack 贡献者。基于 Apache 2.0 许可发布。" }, + "modelpackProject": { "other": "ModelPack 开源项目" }, + "onThisPage": { "other": "本页目录" }, + "sourceDocs": { "other": "查看源文档" }, + "backHome": { "other": "返回官网" }, + "improveDocs": { "other": "反馈文档问题" }, + "codeBlock": { "other": "代码示例" } +} diff --git a/website/layouts/_markup/render-codeblock.html b/website/layouts/_markup/render-codeblock.html new file mode 100644 index 00000000..f33ef327 --- /dev/null +++ b/website/layouts/_markup/render-codeblock.html @@ -0,0 +1,10 @@ +{{ $id := printf "code-%d" .Ordinal }} +
+
+ {{ .Type | default (i18n "codeBlock") }} + +
+
{{ .Inner }}
+
diff --git a/website/layouts/baseof.html b/website/layouts/baseof.html new file mode 100644 index 00000000..8cd8b956 --- /dev/null +++ b/website/layouts/baseof.html @@ -0,0 +1,34 @@ + + + + + + + {{ .Title }} · {{ .Site.Title }} + + + + + + + {{ range .AllTranslations }} + + {{ end }} + {{ range first 1 .AllTranslations }} + + {{ end }} + + + + + + + + {{ partial "header.html" . }} + {{ block "main" . }}{{ end }} + {{ partial "footer.html" . }} +
+ + diff --git a/website/layouts/docs/single.html b/website/layouts/docs/single.html new file mode 100644 index 00000000..6f07fc77 --- /dev/null +++ b/website/layouts/docs/single.html @@ -0,0 +1,20 @@ +{{ define "main" }} +
+ + +
+{{ end }} diff --git a/website/layouts/home.html b/website/layouts/home.html new file mode 100644 index 00000000..75836449 --- /dev/null +++ b/website/layouts/home.html @@ -0,0 +1,106 @@ +{{ define "main" }} +{{ $docs := (.Site.GetPage "getting-started").RelPermalink }} +
+
+
+ + {{ .Params.hero.eyebrow }} + +

+ {{ range $index, $line := .Params.hero.lines }} + {{ if $index }}
{{ end }}{{ $line }} + {{ end }} +

+

{{ .Params.hero.description }}

+ +
+ {{ hugo.Data.commands.install }} + +
+

{{ i18n "openSourceCLI" }}/{{ i18n "builtOnModelpack" }}

+
+ +
+ {{ partial "standards.html" . }} +
+
+
+

{{ .Params.why.eyebrow }}

+

{{ .Params.why.title }}
{{ .Params.why.subtitle }}

+
+

{{ .Params.why.description }}

+
+
+ {{ range .Params.features }} +
+ {{ partial (printf "visuals/%s.html" .visual) $ }} +

{{ .title }}

+

{{ .description }}

+ {{ .link }} +
+ {{ end }} +
+
+ {{ partial "workflow.html" . }} +
+
+

{{ .Params.ecosystem.eyebrow }}

+

{{ .Params.ecosystem.title }}
{{ .Params.ecosystem.subtitle }}

+

{{ .Params.ecosystem.description }}

+ {{ i18n "exploreSpec" }} +
+ +
+
+
+
+

{{ .Params.community.eyebrow }}

+

{{ .Params.community.title }}
{{ .Params.community.subtitle }}

+

{{ .Params.community.description }}

+ +
+ +
+
+
+{{ end }} diff --git a/website/layouts/partials/footer.html b/website/layouts/partials/footer.html new file mode 100644 index 00000000..e4f6a794 --- /dev/null +++ b/website/layouts/partials/footer.html @@ -0,0 +1,35 @@ + diff --git a/website/layouts/partials/header.html b/website/layouts/partials/header.html new file mode 100644 index 00000000..8b85cda4 --- /dev/null +++ b/website/layouts/partials/header.html @@ -0,0 +1,35 @@ +{{ $home := .Site.Home.RelPermalink }} +{{ $docs := .Site.GetPage "getting-started" }} + diff --git a/website/layouts/partials/icons/copy.html b/website/layouts/partials/icons/copy.html new file mode 100644 index 00000000..3fcad410 --- /dev/null +++ b/website/layouts/partials/icons/copy.html @@ -0,0 +1,4 @@ + diff --git a/website/layouts/partials/icons/github.html b/website/layouts/partials/icons/github.html new file mode 100644 index 00000000..b5bbcc11 --- /dev/null +++ b/website/layouts/partials/icons/github.html @@ -0,0 +1,3 @@ + diff --git a/website/layouts/partials/standards.html b/website/layouts/partials/standards.html new file mode 100644 index 00000000..984f2980 --- /dev/null +++ b/website/layouts/partials/standards.html @@ -0,0 +1,17 @@ +
+ +
diff --git a/website/layouts/partials/visuals/fetch.html b/website/layouts/partials/visuals/fetch.html new file mode 100644 index 00000000..e69ff126 --- /dev/null +++ b/website/layouts/partials/visuals/fetch.html @@ -0,0 +1,8 @@ + diff --git a/website/layouts/partials/visuals/package.html b/website/layouts/partials/visuals/package.html new file mode 100644 index 00000000..58a72c86 --- /dev/null +++ b/website/layouts/partials/visuals/package.html @@ -0,0 +1,9 @@ + diff --git a/website/layouts/partials/visuals/registry.html b/website/layouts/partials/visuals/registry.html new file mode 100644 index 00000000..971b6db3 --- /dev/null +++ b/website/layouts/partials/visuals/registry.html @@ -0,0 +1,8 @@ + diff --git a/website/layouts/partials/workflow.html b/website/layouts/partials/workflow.html new file mode 100644 index 00000000..7c95a74e --- /dev/null +++ b/website/layouts/partials/workflow.html @@ -0,0 +1,44 @@ +
+
+
+

{{ .Params.workflow.eyebrow }}

+

{{ .Params.workflow.title }}
{{ .Params.workflow.subtitle }}

+

{{ .Params.workflow.description }}

+
+ {{ range $index, $step := .Params.workflow.steps }} + + {{ end }} +
+
+
+ {{ range $index, $step := .Params.workflow.steps }} +
+
+ + ~/my-modelbash +
+
+

# {{ .comment }}

+
{{ index hugo.Data.commands .id }}
+
+
+ {{ range .results }}

{{ . }}

{{ end }} +
+ +
+
+ {{ i18n "workflowExample" }} + +
+
+ {{ end }} +

{{ .Params.workflow.note }}

+
+
+
diff --git a/website/package.json b/website/package.json new file mode 100644 index 00000000..148d3db2 --- /dev/null +++ b/website/package.json @@ -0,0 +1,13 @@ +{ + "name": "modctl-website", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "hugo server --bind localhost --port 4173 --baseURL http://localhost:4173/", + "build": "hugo --gc --cleanDestinationDir --destination dist --printI18nWarnings --panicOnWarning", + "test": "node --test scripts/*.test.mjs" + } +} diff --git a/website/scripts/site.test.mjs b/website/scripts/site.test.mjs new file mode 100644 index 00000000..24c364aa --- /dev/null +++ b/website/scripts/site.test.mjs @@ -0,0 +1,283 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { cp, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const siteRoot = fileURLToPath(new URL('../', import.meta.url)); +const baseURL = new URL('https://modelpack.github.io/modctl/'); + +async function files(directory, prefix = '') { + const result = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const name = `${prefix}${entry.name}`; + if (entry.isDirectory()) result.push(...await files(join(directory, entry.name), `${name}/`)); + else result.push(name); + } + return result.sort(); +} + +function attributes(tag) { + return Object.fromEntries([...tag.matchAll(/\s([\w:-]+)\s*=\s*(['"])(.*?)\2/gs)] + .map(([, name, , value]) => [name.toLowerCase(), value.replaceAll('&', '&')])); +} + +function decodeHTML(text) { + const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }; + return text.replace(/&(amp|lt|gt|quot|apos|#\d+|#x[\da-f]+);/gi, (_, entity) => + entity.startsWith('#') + ? String.fromCodePoint(entity[1].toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : Number(entity.slice(1))) + : named[entity.toLowerCase()]); +} + +function pageURL(file) { + return new URL(file.replace(/index\.html$/, ''), baseURL).href; +} + +function hugo(args) { + const result = spawnSync(process.env.HUGO_BINARY || 'hugo', args, + { cwd: siteRoot, encoding: 'utf8', timeout: 30000 }); + assert.ifError(result.error); + assert.equal(result.status, 0, `Hugo failed:\n${result.stdout}\n${result.stderr}`); +} + +function contentShape(value, path) { + if (Array.isArray(value)) { + assert.ok(value.length > 0, `${path}: empty content list`); + return value.map((entry, index) => contentShape(entry, `${path}[${index}]`)); + } + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)) + .map(([key, entry]) => [key, contentShape(entry, `${path}.${key}`)])); + } + assert.notEqual(value, null, `${path}: null content`); + if (typeof value === 'string') assert.ok(value.trim(), `${path}: empty content`); + return typeof value; +} + +test('every language has matching, nonempty UI translations and content files', async () => { + const languages = (await readdir(join(siteRoot, 'i18n'))) + .filter((name) => name.endsWith('.json')).map((name) => name.slice(0, -5)).sort(); + assert.ok(languages.includes('en') && languages.includes('zh')); + const contentLanguages = (await readdir(join(siteRoot, 'content'), { withFileTypes: true })) + .filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); + assert.deepEqual(contentLanguages, languages, 'each language needs both i18n and content'); + + const english = JSON.parse(await readFile(join(siteRoot, 'i18n/en.json'), 'utf8')); + const keys = Object.keys(english).sort(); + const contentFiles = await files(join(siteRoot, 'content/en')); + assert.ok(keys.length > 0, 'UI translations must not be empty'); + assert.ok(contentFiles.length > 0, 'English content must not be empty'); + for (const language of languages) { + const messages = JSON.parse(await readFile(join(siteRoot, 'i18n', `${language}.json`), 'utf8')); + assert.deepEqual(Object.keys(messages).sort(), keys, `${language}: UI translation keys differ`); + for (const [key, message] of Object.entries(messages)) { + assert.ok(key.trim(), `${language}: empty translation key`); + assert.deepEqual(Object.keys(message ?? {}), ['other'], `${language}.${key}: expected { other: string }`); + assert.equal(typeof message.other, 'string', `${language}.${key}: translation must be text`); + assert.ok(message.other.trim(), `${language}.${key}: translation must not be empty`); + } + const directory = join(siteRoot, 'content', language); + assert.deepEqual(await files(directory), contentFiles, `${language}: missing corresponding content files`); + for (const file of contentFiles.filter((name) => name.endsWith('.md'))) { + assert.ok((await readFile(join(directory, file), 'utf8')).trim(), `${language}/${file}: empty content`); + } + } +}); + +test('translated frontmatter has the same structure and complete homepage sections', async (t) => { + const temporary = await mkdtemp(join(process.env.JCODE_SCRATCH_DIR || tmpdir(), 'modctl-content-')); + t.after(() => rm(temporary, { recursive: true, force: true })); + hugo(['convert', 'toJSON', '--output', temporary]); + const languages = (await readdir(join(siteRoot, 'content'), { withFileTypes: true })) + .filter((entry) => entry.isDirectory()).map((entry) => entry.name); + for (const file of (await files(join(siteRoot, 'content/en'))).filter((name) => name.endsWith('.md'))) { + let english; + for (const language of ['en', ...languages.filter((name) => name !== 'en')]) { + const name = `${language}/${file}`; + const source = await readFile(join(temporary, name), 'utf8'); + // Hugo's JSON conversion emits an indented object before the Markdown body. + const frontmatter = source.match(/^\{[\s\S]*?^\}/m)?.[0]; + assert.ok(frontmatter, `${name}: missing frontmatter`); + const content = JSON.parse(frontmatter); + const shape = contentShape(content, name); + if (language === 'en') english = content; + assert.deepEqual(shape, contentShape(english, `en/${file}`), `${name}: frontmatter structure differs`); + assert.equal(content.translationKey, english.translationKey, `${name}: translationKey differs`); + if (file === '_index.md') { + assert.deepEqual(Object.keys(content.hero ?? {}).sort(), ['description', 'eyebrow', 'lines'], `${name}: hero`); + assert.equal(content.hero.lines.length, 3, `${name}: hero lines`); + assert.equal(content.features?.length, 3, `${name}: feature cards`); + assert.deepEqual(content.workflow?.steps?.map((step) => step.id), ['build', 'push', 'pull'], `${name}: workflow steps`); + } else { + assert.ok(source.slice(frontmatter.length).trim(), `${name}: missing Markdown body`); + } + } + } +}); + +test('Hugo builds a complete multilingual site without warnings', async (t) => { + const temporary = await mkdtemp(join(process.env.JCODE_SCRATCH_DIR || tmpdir(), 'modctl-hugo-')); + t.after(() => rm(temporary, { recursive: true, force: true })); + const output = join(temporary, 'dist'); + hugo([ + '--gc', '--cleanDestinationDir', '--destination', output, + '--printI18nWarnings', '--panicOnWarning', + ]); + const outputFiles = await files(output); + const htmlFiles = outputFiles.filter((file) => file.endsWith('.html')); + + await t.test('publishes both languages and the shared static assets', () => { + for (const file of [ + 'index.html', 'getting-started/index.html', + 'zh/index.html', 'zh/getting-started/index.html', 'styles.css', 'app.js', + ]) assert.ok(outputFiles.includes(file), `missing output: ${file}`); + assert.ok(outputFiles.some((file) => file.startsWith('assets/')), 'missing shared assets'); + for (const file of outputFiles) { + assert.doesNotMatch(file, /^(?:content\/|i18n\/|layouts\/|scripts\/|package\.json$|README\.md$)/, + `published source file: ${file}`); + } + }); + + await t.test('local href, src, CSS URLs, and fragment anchors resolve under /modctl/', async () => { + for (const file of outputFiles.filter((name) => /\.(?:html|css)$/.test(name))) { + const source = await readFile(join(output, file), 'utf8'); + const references = file.endsWith('.css') + ? [...source.matchAll(/url\(\s*['"]?([^'"\s)]+)['"]?\s*\)/g)].map((match) => match[1]) + : [...source.matchAll(/\s(?:href|src)\s*=\s*(['"])(.*?)\1/gs)].map((match) => match[2]); + for (const reference of references) { + const target = new URL(reference.replaceAll('&', '&'), pageURL(file)); + if (target.origin !== baseURL.origin) continue; + assert.ok(target.pathname.startsWith(baseURL.pathname), `${file}: ${reference} escapes /modctl/`); + let targetPath = decodeURIComponent(target.pathname.slice(baseURL.pathname.length)); + if (!targetPath || targetPath.endsWith('/')) targetPath += 'index.html'; + assert.ok(outputFiles.includes(targetPath), `${file}: missing local link ${reference}`); + assert.ok((await stat(join(output, targetPath))).isFile(), `${file}: not a file: ${reference}`); + if (target.hash && targetPath.endsWith('.html')) { + const targetHTML = await readFile(join(output, targetPath), 'utf8'); + const ids = [...targetHTML.matchAll(/\sid\s*=\s*(['"])(.*?)\1/gs)].map((match) => match[2]); + assert.ok(ids.includes(decodeURIComponent(target.hash.slice(1))), `${file}: missing anchor ${reference}`); + } + } + } + }); + + await t.test('pages declare their language, canonical URL, and reciprocal translations', async () => { + for (const file of htmlFiles) { + const html = await readFile(join(output, file), 'utf8'); + const links = [...html.matchAll(/]*>/gi)].map((match) => attributes(match[0])); + const canonicals = links.filter((link) => link.rel === 'canonical'); + assert.equal(canonicals.length, 1, `${file}: expected one canonical URL`); + assert.doesNotMatch(html, /\bdata-zh\b/i, `${file}: legacy inline translation`); + const redirect = [...html.matchAll(/]*>/gi)].map((match) => attributes(match[0])) + .find((meta) => meta['http-equiv']?.toLowerCase() === 'refresh'); + if (redirect) { + const target = redirect.content?.match(/^0;\s*url=(.+)$/i)?.[1]; + assert.ok(target, `${file}: missing alias redirect target`); + assert.equal(new URL(target, pageURL(file)).href, canonicals[0].href, `${file}: alias canonical`); + assert.notEqual(canonicals[0].href, pageURL(file), `${file}: alias redirects to itself`); + continue; + } + const language = file.startsWith('zh/') ? 'zh-CN' : 'en'; + assert.equal(attributes(html.match(/]*>/i)?.[0] ?? '').lang, language, `${file}: html lang`); + assert.equal(canonicals[0].href, pageURL(file), `${file}: canonical URL`); + const englishFile = file.replace(/^zh\//, ''); + const translations = { en: englishFile, 'zh-CN': `zh/${englishFile}` }; + for (const [code, translatedFile] of Object.entries(translations)) { + assert.ok(outputFiles.includes(translatedFile), `${file}: missing ${code} page`); + const alternate = links.filter((link) => link.rel === 'alternate' && link.hreflang === code); + assert.equal(alternate.length, 1, `${file}: expected one ${code} alternate`); + assert.equal(alternate[0].href, pageURL(translatedFile), `${file}: ${code} alternate URL`); + } + } + }); + + await t.test('each Markdown code fence renders an accessible copy button targeting its exact code', async () => { + for (const language of ['en', 'zh']) { + const markdown = await readFile(join(siteRoot, 'content', language, 'getting-started.md'), 'utf8'); + const expected = [...markdown.matchAll(/^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)^\1\s*$/gm)].map((match) => match[2].trim()); + assert.ok(expected.length > 0, `${language}: missing documentation examples`); + const html = await readFile(join(output, language === 'en' ? '' : language, 'getting-started/index.html'), 'utf8'); + const blocks = [...html.matchAll(/
[\s\S]*?<\/code><\/pre>\s*<\/div>/g)].map((match) => match[0]); + assert.equal(blocks.length, expected.length, `${language}: code render hook must wrap every fence`); + assert.equal([...html.matchAll(/\sdata-copy-target=/g)].length, blocks.length, `${language}: copy button count`); + const ids = new Set(); + blocks.forEach((block, index) => { + const buttons = [...block.matchAll(/]*>/g)].map((match) => attributes(match[0])); + assert.equal(buttons.length, 1, `${language}: code block ${index} needs one copy button`); + const code = block.match(/]*)>([\s\S]*?)<\/code>/); + const id = attributes(code?.[1] ?? '').id; + assert.ok(id && !ids.has(id), `${language}: duplicate or missing code ID`); + ids.add(id); + assert.equal(buttons[0]['data-copy-target'], id, `${language}: copy button targets the wrong code`); + assert.ok(buttons[0]['aria-label']?.trim(), `${language}: copy button needs an accessible label`); + assert.equal(decodeHTML(code[2]).trim(), expected[index], `${language}: copied code differs from Markdown fence ${index}`); + assert.doesNotMatch(block, /\shidden(?:\s|=|>)/, `${language}: examples must be readable without JavaScript`); + }); + } + }); + + await t.test('homepages ship all workflow commands and native language links without JavaScript', async () => { + const commands = JSON.parse(await readFile(join(siteRoot, 'data/commands.json'), 'utf8')); + for (const file of ['index.html', 'zh/index.html']) { + const html = await readFile(join(output, file), 'utf8'); + assert.match(attributes(html.match(/]*>/)[0]).class, /\bno-js\b/); + const panels = [...html.matchAll(/]*\bdata-workflow-panel[^>]*>/g)].map((match) => match[0]); + assert.equal(panels.length, 3, `${file}: all three workflow panels must be server-rendered`); + for (const step of ['build', 'push', 'pull']) { + const panel = panels.find((tag) => attributes(tag).id === `panel-${step}`); + assert.ok(panel, `${file}: missing ${step} panel`); + assert.doesNotMatch(panel, /\shidden(?:\s|=|>)|aria-hidden=['"]true/, `${file}: no-JS panel is hidden`); + const code = html.match(new RegExp(`([\\s\\S]*?)<\\/code>`)); + assert.ok(code, `${file}: missing rendered ${step} command`); + assert.equal(decodeHTML(code[1]).trim(), commands[step].trim(), `${file}: workflow code differs`); + const tab = [...html.matchAll(/]*>/g)].map((match) => attributes(match[0])) + .find((button) => button.id === `tab-${step}`); + assert.equal(tab?.['aria-controls'], `panel-${step}`, `${file}: tab must target its panel`); + } + const languageLinks = [...html.matchAll(/]*>/g)].map((match) => attributes(match[0])) + .filter((link) => link.hreflang); + assert.ok(languageLinks.some((link) => link.href === (file.startsWith('zh/') ? '/modctl/' : '/modctl/zh/')), + `${file}: language switching requires a native link`); + } + }); + + await t.test('JavaScript has no client-side language switching or translation table', async () => { + for (const file of outputFiles.filter((name) => name.endsWith('.js'))) { + const script = await readFile(join(output, file), 'utf8'); + assert.doesNotMatch(script, + /\b(?:setLanguage|localeIndex)\b|modctl-language|\bdata-zh\b|document\.documentElement\.lang|\b(?:language|locale)\s*={2,3}\s*['"]/, + `${file}: translations belong in Hugo content and i18n files`); + } + }); +}); + +test('localized SVG labels escape special characters and markup as text', async (t) => { + const temporary = await mkdtemp(join(process.env.JCODE_SCRATCH_DIR || tmpdir(), 'modctl-svg-')); + t.after(() => rm(temporary, { recursive: true, force: true })); + const source = join(temporary, 'site'); + const output = join(temporary, 'dist'); + await cp(siteRoot, source, { + recursive: true, + filter: (path) => !['dist', 'resources', '.hugo_build.lock'].includes(basename(path)), + }); + const keys = ['artifactWeights', 'artifactConfig', 'artifactCode', 'artifactDocs']; + const payload = 'Weights & '; + for (const language of ['en', 'zh']) { + const path = join(source, 'i18n', `${language}.json`); + const messages = JSON.parse(await readFile(path, 'utf8')); + for (const key of keys) messages[key].other = `${key}: ${payload}`; + await writeFile(path, JSON.stringify(messages)); + } + hugo(['--source', source, '--destination', output, '--ignoreCache', '--printI18nWarnings', '--panicOnWarning']); + for (const language of ['en', 'zh']) { + const svg = await readFile(join(output, 'assets', `artifact.${language}.svg`), 'utf8'); + for (const key of keys) { + const escaped = `${key}: ${payload}`.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + assert.ok(svg.includes(escaped), `${language}.${key}: SVG label must preserve and escape text`); + } + assert.doesNotMatch(svg, /| { + 'use strict'; + + const menuButton = document.querySelector('.menu-toggle'); + const navigation = document.querySelector('.navigation'); + const toast = document.querySelector('.toast'); + const tabs = [...document.querySelectorAll('[data-step]')]; + let toastTimeout; + + // All content and localized labels are rendered by Hugo. This file only + // enhances native HTML controls, never selects or replaces a language. + function selectTab(selected) { + tabs.forEach(tab => { + const active = tab === selected; + tab.classList.toggle('active', active); + tab.setAttribute('aria-selected', String(active)); + tab.tabIndex = active ? 0 : -1; + document.getElementById(tab.getAttribute('aria-controls')).hidden = !active; + }); + } + + function setMenu(open, returnFocus = false) { + if (!menuButton || !navigation) return; + navigation.classList.toggle('open', open); + menuButton.setAttribute('aria-expanded', String(open)); + menuButton.setAttribute('aria-label', open ? menuButton.dataset.closeLabel : menuButton.dataset.openLabel); + if (returnFocus) menuButton.focus(); + } + + async function copyText(text) { + try { + if (!navigator.clipboard?.writeText) throw new Error('Clipboard API unavailable'); + await navigator.clipboard.writeText(text); + return true; + } catch { + const previousFocus = document.activeElement; + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0'; + document.body.append(textarea); + textarea.select(); + let copied = false; + try { copied = document.execCommand('copy'); } catch { /* Show the rendered fallback message. */ } + textarea.remove(); + previousFocus?.focus({ preventScroll: true }); + return copied; + } + } + + document.addEventListener('click', async event => { + const button = event.target.closest('[data-copy-target]'); + if (!button) return; + const text = document.getElementById(button.dataset.copyTarget)?.textContent; + if (!text) return; + button.disabled = true; + const success = await copyText(text.trim()); + button.disabled = false; + if (!toast) return; + clearTimeout(toastTimeout); + toast.textContent = success ? toast.dataset.copySuccess : toast.dataset.copyFailure; + toast.classList.add('visible'); + toastTimeout = setTimeout(() => toast.classList.remove('visible'), 2800); + }); + + tabs.forEach((tab, index) => { + tab.addEventListener('click', () => selectTab(tab)); + tab.addEventListener('keydown', event => { + let next; + if (event.key === 'ArrowDown' || event.key === 'ArrowRight') next = (index + 1) % tabs.length; + if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') next = (index + tabs.length - 1) % tabs.length; + if (event.key === 'Home') next = 0; + if (event.key === 'End') next = tabs.length - 1; + if (next === undefined) return; + event.preventDefault(); + tabs[next].focus(); + selectTab(tabs[next]); + }); + }); + if (tabs.length) selectTab(tabs[0]); + + menuButton?.addEventListener('click', () => setMenu(menuButton.getAttribute('aria-expanded') !== 'true')); + navigation?.querySelectorAll('a').forEach(link => link.addEventListener('click', () => setMenu(false))); + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && navigation?.classList.contains('open')) setMenu(false, true); + }); + document.addEventListener('click', event => { + if (!event.target.closest('.site-header')) setMenu(false); + }); + window.matchMedia('(max-width: 940px)').addEventListener('change', () => setMenu(false)); + + const docLinks = [...document.querySelectorAll('.docs-sidebar nav a')]; + if (docLinks.length && 'IntersectionObserver' in window) { + const observer = new IntersectionObserver(entries => { + const entry = entries.find(item => item.isIntersecting); + if (!entry) return; + docLinks.forEach(link => { + const current = link.hash === `#${entry.target.id}`; + link.classList.toggle('current', current); + if (current) link.setAttribute('aria-current', 'location'); + else link.removeAttribute('aria-current'); + }); + }, { rootMargin: '-15% 0px -60% 0px', threshold: 0 }); + document.querySelectorAll('.docs-prose h2[id]').forEach(heading => observer.observe(heading)); + } + + document.documentElement.classList.remove('no-js'); +})(); diff --git a/website/static/assets/manrope-LICENSE.txt b/website/static/assets/manrope-LICENSE.txt new file mode 100644 index 00000000..793d053a --- /dev/null +++ b/website/static/assets/manrope-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/manrope) + +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/website/static/assets/manrope.ttf b/website/static/assets/manrope.ttf new file mode 100644 index 00000000..75274da5 Binary files /dev/null and b/website/static/assets/manrope.ttf differ diff --git a/website/static/assets/mark.svg b/website/static/assets/mark.svg new file mode 100644 index 00000000..1a58f802 --- /dev/null +++ b/website/static/assets/mark.svg @@ -0,0 +1 @@ + diff --git a/website/static/assets/mono-LICENSE.txt b/website/static/assets/mono-LICENSE.txt new file mode 100644 index 00000000..670c6c04 --- /dev/null +++ b/website/static/assets/mono-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +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/website/static/assets/mono.ttf b/website/static/assets/mono.ttf new file mode 100644 index 00000000..02d682ca Binary files /dev/null and b/website/static/assets/mono.ttf differ diff --git a/website/static/styles.css b/website/static/styles.css new file mode 100644 index 00000000..fcf46a75 --- /dev/null +++ b/website/static/styles.css @@ -0,0 +1,1573 @@ +@font-face{font-family:Manrope; +src:url('./assets/manrope.ttf') format('truetype'); +font-weight:400 800; +font-display:swap} + +@font-face{font-family:'IBM Plex Mono'; +src:url('./assets/mono.ttf') format('truetype'); +font-weight:400; +font-display:swap} + +:root{--paper:#f8fafc; +--white:#fff; +--ink:#152137; +--muted:#647084; +--line:#e1e6ef; +--blue:#365cf5; +--blue-deep:#2847d0; +--blue-wash:#edf2ff; +--night:#111a2b; +--mono:'IBM Plex Mono',monospace; +--sans:Manrope,'PingFang SC','Microsoft YaHei',sans-serif} + +*{box-sizing:border-box} +html{scroll-behavior:smooth; +scroll-padding-top:110px} +body{margin:0; +background:var(--paper); +color:var(--ink); +font-family:var(--sans); +font-size:15px; +-webkit-font-smoothing:antialiased} +button,a{-webkit-tap-highlight-color:transparent} +a{color:inherit; +text-decoration:none} +button{font:inherit; +cursor:pointer} +button,select{color:inherit} +button:disabled{cursor:wait} +button:focus-visible,a:focus-visible,[tabindex]:focus-visible,summary:focus-visible{outline:3px solid #6080ff; +outline-offset:5px} +img,svg{vertical-align:middle} +img{max-width:100%} +h1,h2,h3,p{margin:0} +button{border:0} +::selection{background:#cad6ff; +color:#142758} +.container{width:calc(100% - 112px); +max-width:1248px; +margin-inline:auto} +.eyebrow{font-family:var(--mono); +font-size:10px; +font-weight:400; +letter-spacing:1.45px; +line-height:1.8} +.text-blue{color:var(--blue)} +.text-muted{color:#758196} +.dark-muted{color:#9daabe} +.status-dot{display:inline-block; +width:6px; +height:6px; +flex-shrink:0; +border-radius:50%; +background:var(--blue)} +.skip-link{position:fixed; +top:-80px; +left:20px; +padding:12px 20px; +background:var(--ink); +color:white; +z-index:100} +.skip-link:focus{top:12px} +.site-header{position:sticky; +top:0; +z-index:30; +background:rgba(248,250,252,.94); +backdrop-filter:blur(18px); +border-bottom:1px solid var(--line)} +.header-inner{height:85px; +display:flex; +align-items:center; +justify-content:space-between; +gap:44px} +.brand{display:inline-flex; +align-items:center; +gap:10px; +flex-shrink:0; +font-size:27px; +font-weight:800; +letter-spacing:-1.1px; +line-height:1} +.brand-dot{color:var(--blue)} +.navigation{display:flex; +align-items:center; +gap:31px; +font-size:12px; +font-weight:600} +.navigation>a{transition:color .2s} +.navigation>a:hover{color:var(--blue)} +.nav-actions{display:flex; +align-items:center; +gap:21px; +margin-left:21px} +.language-toggle{display:flex; +align-items:center; +gap:6px; +font-size:11px; +background:none; +padding:9px 0; +min-height:36px} +.globe{font-size:20px; +font-weight:400} +.github-nav svg{width:19px; +height:19px} +.button{display:inline-flex; +justify-content:center; +align-items:center; +gap:28px; +min-height:49px; +padding:0 23px; +border-radius:5px; +font-size:12px; +font-weight:700; +line-height:1.5; +transition:background .2s,transform .2s,box-shadow .2s} +.button>span:last-child[aria-hidden]{font-size:19px; +font-weight:400} +.button:hover{transform:translateY(-2px)} +.button-blue{background:var(--blue); +color:white; +box-shadow:0 3px 5px #365cf514} +.button-blue:hover{background:var(--blue-deep); +box-shadow:0 6px 18px #365cf52b} +.button-dark{background:var(--ink); +color:var(--white)} +.button-dark:hover{background:#28374f} +.button-small{min-height:38px; +padding:0 15px; +gap:16px; +font-size:11px} +.menu-toggle{display:none} +.hero{display:grid; +grid-template-columns:1fr 1.04fr; +min-height:742px; +align-items:center; +position:relative; +padding-block:68px 73px; +gap:10px} +.hero-content{position:relative; +z-index:2} +.hero-eyebrow{display:inline-flex; +align-items:center; +gap:9px; +padding:5px 10px; +border:1px solid #dce3ef; +border-radius:4px; +font-size:9px; +letter-spacing:1.1px; +margin-bottom:26px} +.eyebrow-arrow{margin-left:14px; +color:var(--muted); +font-size:15px} +.hero h1{font-size:clamp(56px,5.65vw,81px); +line-height:1.065; +letter-spacing:-4.8px; +font-weight:600} +.hero-description{font-size:15px; +line-height:1.9; +max-width:425px; +color:var(--muted); +margin-top:27px} +.hero-actions{display:flex; +align-items:center; +gap:26px; +margin-top:28px} +.text-link{display:inline-flex; +align-items:center; +gap:15px; +font-size:12px; +font-weight:600; +transition:color .2s} +.text-link:hover{color:var(--blue)} +.text-link>span:last-child[aria-hidden]{font-size:18px} +.install-command{display:flex; +align-items:center; +gap:12px; +width:fit-content; +max-width:100%; +margin-top:32px; +background:#f0f3f8; +border:1px solid #e3e8f0; +border-radius:5px; +padding:5px 7px 5px 15px; +min-height:42px} +.install-command code{font-family:var(--mono); +font-size:10px; +white-space:nowrap; +overflow-x:auto} +.prompt{font-family:var(--mono); +font-size:12px; +color:#7a8ba5} +.copy-button{width:30px; +height:30px; +flex-shrink:0; +display:flex; +align-items:center; +justify-content:center; +background:transparent; +border-radius:4px; +color:var(--muted)} +.copy-button svg{width:16px; +height:16px; +fill:none; +stroke:currentColor; +stroke-width:1.2} +.copy-button:hover{background:#e0e7f4; +color:var(--blue)} +.hero-footnote{display:flex; +align-items:center; +gap:7px; +color:#657185; +font-size:10px; +margin-top:13px} +.tiny-check{color:#577960} +.footnote-separator{margin-inline:5px; +color:#c0c7d1} +.hero-art{position:relative; +min-width:0; +height:590px; +margin-right:-25px; +isolation:isolate} +.art-grid{position:absolute; +inset:25px -10px 24px -20px; +z-index:-2; +background-image:linear-gradient(#dae2f24d 1px,transparent 1px),linear-gradient(90deg,#dae2f24d 1px,transparent 1px); +background-size:42px 42px; +mask-image:radial-gradient(ellipse at center,#000 20%,transparent 71%)} +.art-halo{position:absolute; +inset:30px 25px; +z-index:-1; +background:radial-gradient(ellipse,#e4ebff8c,transparent 65%)} +.artifact-image{width:105%; +max-width:none; +height:auto; +position:absolute; +top:14px; +left:-5%; +animation:artifact-arrive 1.1s cubic-bezier(.2,.7,.1,1) both} +.art-kicker{position:absolute; +top:14px; +left:36px; +display:flex; +gap:10px; +align-items:center; +font-family:var(--mono); +color:#7e8ba1; +font-size:8px; +letter-spacing:1.6px} +.crosshair{font-size:18px; +color:#94a3bb} +.artifact-badge{position:absolute; +right:34px; +bottom:79px; +display:flex; +align-items:center; +gap:12px; +padding:15px 18px; +background:rgba(255,255,255,.91); +border:1px solid #dde5f6; +border-radius:7px; +box-shadow:0 8px 32px #2e4c8f09; +transform:rotate(-5deg)} +.badge-cube{display:flex; +align-items:center; +justify-content:center; +background:#eef2ff; +color:var(--blue); +width:36px; +height:36px; +border-radius:5px; +font-size:29px} +.artifact-badge strong{font-size:12px; +font-weight:700; +display:block} +.artifact-badge div>span{font-size:10px; +color:#72809b; +display:block; +margin-top:4px} +.badge-check{background:#eef6f2; +border:1px solid #dcece4; +color:#4f8f79; +font-size:10px; +display:grid; +place-items:center; +width:18px; +height:18px; +border-radius:50%; +margin-left:15px} +.art-caption{position:absolute; +bottom:27px; +left:70px; +right:35px; +display:flex; +align-items:center; +gap:12px; +font-family:var(--mono); +font-size:8px; +letter-spacing:1.2px; +color:#6e7e95} +.caption-line{flex:1; +height:1px; +background:#dbe3f1; +max-width:150px} +.standards-bar{border-block:1px solid var(--line); +background:#f3f6fa} +.standards-inner{min-height:126px; +display:flex; +align-items:center; +justify-content:space-between; +gap:28px} +.standards-inner>.eyebrow{font-size:9px; +letter-spacing:1px; +max-width:150px; +color:var(--muted)} +.standard-brand{display:flex; +align-items:center; +gap:14px; +font-size:20px; +font-weight:700; +letter-spacing:-.6px; +color:#49546a; +transition:color .2s} +.standard-brand:hover{color:var(--blue)} +.standard-brand svg{width:33px; +height:33px; +fill:none; +stroke:currentColor; +stroke-width:1.4} +.standard-brand small{font-size:9px; +letter-spacing:0; +font-weight:400; +color:#657185; +display:block; +margin-top:6px} +.oci-wordmark{font-size:35px; +letter-spacing:-2px; +font-weight:800; +display:flex; +align-items:center; +gap:5px} +.oci-wordmark>span{font-size:24px; +transform:rotate(-90deg); +letter-spacing:0} +.standard-label{font-size:10px; +letter-spacing:0; +font-weight:600} +.section{padding-block:107px} +.section-heading{display:flex; +align-items:flex-end; +justify-content:space-between; +gap:45px; +margin-bottom:45px} +.section .eyebrow{color:var(--muted); +margin-bottom:18px} +h2{font-size:43px; +line-height:1.22; +letter-spacing:-1.9px; +font-weight:600} +.section-heading>p{max-width:378px; +font-size:13px; +line-height:1.9; +color:var(--muted); +margin-bottom:4px} +.feature-grid{display:grid; +grid-template-columns:repeat(3,1fr); +gap:22px} +.feature-card{background:white; +border:1px solid var(--line); +border-radius:8px; +padding:25px 26px 26px; +display:flex; +flex-direction:column; +transition:box-shadow .25s,border-color .25s} +.feature-card:hover{box-shadow:0 9px 28px #1f397008; +border-color:#c4d1ec} +.feature-card h3{font-size:17px; +font-weight:700; +letter-spacing:-.5px; +line-height:1.4} +.feature-card>p{font-size:12px; +line-height:1.95; +color:var(--muted); +margin-top:13px; +flex:1} +.inline-link{display:inline-flex; +align-items:center; +gap:16px; +font-size:11px; +font-weight:700; +line-height:1.7} +.inline-link:hover{color:var(--blue)} +.inline-link>span:last-child[aria-hidden]{font-size:17px} +.feature-card .inline-link{margin-top:25px} +.feature-visual{height:159px; +display:flex; +align-items:center; +justify-content:center; +position:relative; +margin-bottom:28px; +border-bottom:1px solid #f0f3f8; +padding-bottom:20px; +font-family:var(--mono); +font-size:9px} +.file-stack{display:flex; +flex-direction:column; +gap:6px; +transform:rotate(-4deg)} +.file-stack>span{display:flex; +align-items:center; +gap:8px; +padding:9px 10px; +background:#f7f9fc; +border:1px solid #e4e9f2; +border-radius:4px; +box-shadow:2px 3px 4px #142e6504} +.file-dot{width:6px; +height:7px; +border-radius:1px} +.blue-dot{background:#7f9af9} +.mint-dot{background:#7ea897} +.gray-dot{background:#a6b0c0} +.visual-arrow{font-family:var(--sans); +font-size:19px; +color:#a4b4d4; +margin-inline:18px} +.mini-artifact{display:grid; +place-items:center; +gap:6px; +background:#f0f4ff; +border:1px solid #dce5ff; +border-radius:7px; +padding:17px 18px; +transform:rotate(5deg); +color:#53698f; +font-size:8px} +.registry-node{width:48px; +height:52px; +border:1px solid #dce3ef; +background:#f7f9fc; +border-radius:5px; +display:grid; +place-items:center; +margin-top:-18px} +.registry-node svg{width:26px; +height:26px; +fill:none; +stroke:#8999b5; +stroke-width:1.2} +.registry-center{display:grid; +place-items:center; +width:62px; +height:67px; +background:#edf2ff; +border:1px solid #d4dfff; +border-radius:6px; +margin-top:-18px} +.dashed-connector{width:26px; +border-top:1px dashed #bbc9e5; +margin-top:-18px} +.registry-tag{position:absolute; +bottom:26px; +left:0; +right:0; +text-align:center; +font-size:8px; +color:#66758b} +.fetch-list{display:flex; +flex-direction:column; +gap:5px; +width:100%; +max-width:250px; +transform:rotate(-3deg); +margin-bottom:15px} +.fetch-list>span{padding:9px 12px; +display:flex; +align-items:center; +font-size:9px} +.fetch-list i{font-size:7px; +font-style:normal; +margin-left:auto} +.dim-file{background:#f7f9fc; +color:#606e83} +.selected-file{border:1px solid #c9d6ff; +background:#eff3ff; +color:#4161ba; +border-radius:4px; +box-shadow:0 3px 9px #365cf50a} +.selected-file b{margin-left:auto} +.fetch-chip{position:absolute; +bottom:13px; +left:50%; +transform:translateX(-50%); +font-size:8px; +color:#607397; +background:white; +border:1px solid #e1e7f2; +padding:5px 8px; +white-space:nowrap; +border-radius:3px} +.workflow-section{background:var(--night); +color:#f1f4fa; +padding-block:100px; +position:relative; +overflow:hidden} +.workflow-section::after{content:''; +position:absolute; +width:430px; +height:430px; +border:1px solid #27334b; +border-radius:50%; +right:-240px; +top:-280px; +pointer-events:none} +.workflow-inner{display:grid; +grid-template-columns:.9fr 1.1fr; +gap:85px; +align-items:center} +.workflow-copy>.eyebrow{font-size:9px; +color:#9caac3; +letter-spacing:1.1px; +margin-bottom:21px} +.workflow-description{color:#9ba8be; +line-height:1.95; +font-size:12px; +margin-top:23px; +max-width:365px} +.workflow-tabs{margin-top:30px} +.workflow-tab{display:flex; +align-items:center; +gap:17px; +width:100%; +padding:17px 16px; +text-align:left; +background:transparent; +color:#8591a7; +border-left:2px solid #344156; +transition:background .2s,color .2s; +border-radius:0 4px 4px 0} +.step-index{font-family:var(--mono); +font-size:10px; +color:#96a4bd; +align-self:flex-start; +margin-top:4px} +.workflow-tab strong{display:block; +font-size:13px; +font-weight:600} +.workflow-tab small{display:block; +font-size:10px; +margin-top:7px} +.tab-arrow{margin-left:auto; +align-self:flex-start; +font-size:18px; +opacity:0} +.workflow-tab.active{background:#1b2942; +color:#fff; +border-color:#6c8fff} +.workflow-tab.active .step-index{color:#a8beff} +.workflow-tab.active small{color:#a5b6d3} +.workflow-tab.active .tab-arrow{opacity:1; +color:#9cb5ff} +.workflow-tab:hover{background:#1a253a} +.terminal-wrap{min-width:0; +padding-top:28px} +.terminal{background:#0b1321; +border:1px solid #35415a; +border-radius:8px; +box-shadow:0 26px 80px #03080f47; +overflow:hidden; +font-family:var(--mono); +font-size:10px} +.terminal-top{display:flex; +align-items:center; +gap:20px; +color:#9aa8bd; +height:45px; +padding-inline:20px; +background:#182236; +border-bottom:1px solid #2b374c} +.window-dots{display:flex; +gap:5px; +margin-right:auto} +.window-dots i{display:block; +width:7px; +height:7px; +border-radius:50%; +background:#65738a} +.window-dots i:first-child{background:#bd7b7b} +.window-dots i:nth-child(2){background:#c5ac75} +.window-dots i:nth-child(3){background:#6d9a90} +.terminal-shell{margin-left:auto; +color:#8594aa} +.terminal-body{padding:31px 26px 20px; +min-height:333px} +.terminal-comment{color:#8291aa; +font-size:9px; +line-height:1.8; +margin-bottom:22px} +pre{margin:0; +font-family:var(--mono); +line-height:2; +overflow-x:auto; +tab-size:2} +code{font-family:var(--mono)} +.terminal pre{font-size:11px; +min-height:112px; +color:#d6dfef} +.terminal-prompt{color:#8192af; +margin-right:7px} +.code-blue{color:#91aeff} +.terminal-divider{height:1px; +background:#223046; +margin-block:21px} +.terminal-result{font-size:9px; +color:#a2b0c7; +line-height:2.4} +.terminal-result p>span:first-child{color:#8bbda5; +margin-right:9px} +.terminal-cursor{display:block; +width:6px; +height:12px; +background:#8ba7e8; +margin-top:15px; +animation:cursor-blink 2s step-end 3} +.terminal-bottom{display:flex; +align-items:center; +justify-content:space-between; +gap:12px; +padding:12px 16px; +background:#121d30; +border-top:1px solid #263247; +font-size:8px; +color:#a8b6cd; +letter-spacing:.3px} +.terminal-bottom>span{display:flex; +align-items:center; +gap:7px} +.terminal-bottom .status-dot{background:#84b69f; +width:4px; +height:4px} +.terminal-copy{color:#afbdd4; +background:none; +display:flex; +gap:12px; +align-items:center; +font-family:var(--mono); +font-size:8px; +padding:4px} +.terminal-copy:hover{color:white} +.terminal-note{font-size:9px; +color:#a0adc2; +text-align:center; +margin-top:21px; +display:flex; +justify-content:center; +gap:9px} +.terminal-note>span:first-child{color:#6c7a95} +.ecosystem-section{display:grid; +grid-template-columns:.95fr 1.05fr; +gap:80px; +align-items:center; +padding-block:119px} +.ecosystem-copy>p:not(.eyebrow){font-size:13px; +color:var(--muted); +line-height:1.95; +margin-top:22px; +max-width:380px} +.ecosystem-copy>.inline-link{margin-top:25px} +.ecosystem-map{display:flex; +align-items:center; +justify-content:space-between; +gap:0; +min-width:0; +position:relative} +.map-column{position:relative; +z-index:1} +.map-label{position:absolute; +top:-32px; +font-family:var(--mono); +font-size:8px; +letter-spacing:.8px; +color:#65748b; +white-space:nowrap} +.map-node{display:flex; +align-items:center; +gap:10px; +background:white; +border:1px solid #dfe6f0; +border-radius:4px; +padding:12px 13px; +font-size:10px; +min-width:139px} +.map-node+.map-node{margin-top:10px} +.format-icon{display:grid; +place-items:center; +width:19px; +height:19px; +border-radius:4px; +background:#edf1f9; +color:#566781; +font-family:var(--mono); +font-size:9px} +.map-bridge{flex:1; +min-width:110px; +position:relative; +text-align:center} +.map-bridge::before{content:''; +position:absolute; +left:0; +right:0; +top:50%; +border-top:1px dashed #adbfe3; +z-index:-1} +.map-bridge::after{content:''; +position:absolute; +left:0; +top:calc(50% - 83px); +height:166px; +width:28px; +border:1px solid #d8e2f5; +border-left:none; +border-radius:0 16px 16px 0; +z-index:-1} +.map-hub{width:82px; +height:89px; +background:#edf2ff; +border:1px solid #d1ddff; +border-radius:8px; +display:inline-flex; +align-items:center; +justify-content:center; +flex-direction:column; +gap:7px; +box-shadow:0 0 0 7px var(--paper)} +.map-hub strong{font-size:13px; +letter-spacing:-.4px} +.map-hub-caption{position:absolute; +top:calc(100% + 14px); +left:0; +right:0; +color:#5b6b84; +font-family:var(--mono); +font-size:7px; +letter-spacing:1.3px} +.registry-card{display:flex; +align-items:center; +justify-content:center; +flex-direction:column; +text-align:center; +width:146px; +height:156px; +border:1px solid #dce3ee; +background:white; +border-radius:6px} +.registry-card>svg{width:32px; +height:32px; +stroke:#7185ab; +stroke-width:1.3; +fill:none; +margin-bottom:16px} +.registry-card strong{font-size:12px; +font-weight:600} +.registry-card small{font-size:9px; +color:#67758c; +line-height:1.8; +margin-top:8px} +.community-section{padding-bottom:95px} +.community-card{position:relative; +background:#edf2fc; +border:1px solid #dce5f4; +border-radius:9px; +padding:57px 58px; +overflow:hidden; +display:flex; +justify-content:space-between; +gap:15px} +.community-card .eyebrow{color:#596d8c; +font-size:9px; +margin-bottom:20px} +.community-card h2{font-size:38px; +line-height:1.3; +letter-spacing:-1.4px} +.community-card p:not(.eyebrow){font-size:12px; +color:#5d6e88; +margin-top:18px} +.community-actions{display:flex; +align-items:center; +gap:25px; +margin-top:28px} +.community-art{width:285px; +flex-shrink:0; +position:relative; +display:grid; +place-items:center} +.community-mark{width:115px; +height:126px; +display:grid; +place-items:center; +background:#f8faff; +border:1px solid #d7e1f6; +border-radius:18px; +transform:rotate(-10deg); +box-shadow:10px 14px 30px #466cac0c} +.orbit{position:absolute; +left:50%; +top:50%; +border:1px solid #d2ddf1; +border-radius:50%; +transform:translate(-50%,-50%)} +.orbit-one{width:200px; +height:200px} +.orbit-two{width:295px; +height:295px} +.orbit-three{width:390px; +height:390px} +.orbit-node{display:grid; +place-items:center; +position:absolute; +background:#f8faff; +border:1px solid #d9e3f5; +color:#516b9a; +border-radius:6px; +width:29px; +height:29px; +font-family:var(--mono); +font-size:14px} +.node-one{left:12px; +top:40px; +transform:rotate(-10deg)} +.node-two{right:12px; +top:55px; +transform:rotate(12deg)} +.node-three{right:60px; +bottom:4px; +font-size:9px; +transform:rotate(5deg)} +.site-footer{border-top:1px solid var(--line); +padding-top:52px} +.footer-top{display:flex; +justify-content:space-between; +gap:40px; +padding-bottom:51px} +.footer-top .brand{font-size:25px} +.footer-top p{font-size:11px; +color:var(--muted); +margin-top:16px} +.footer-links{display:flex; +gap:87px; +margin-right:40px} +.footer-links>div{display:flex; +flex-direction:column; +gap:13px; +font-size:11px; +color:#66748a} +.footer-links strong{font-family:var(--mono); +font-weight:400; +font-size:9px; +letter-spacing:.9px; +color:#39465d; +margin-bottom:6px} +.footer-links a:hover{color:var(--blue)} +.footer-bottom{padding-block:22px; +border-top:1px solid var(--line); +display:flex; +justify-content:space-between; +align-items:center; +gap:20px; +font-size:9px; +color:#64738a} +.footer-bottom>a{display:flex; +align-items:center; +gap:9px} +.footer-bottom .status-dot{width:4px; +height:4px; +background:#7e9f8f} +.toast{position:fixed; +bottom:25px; +left:50%; +transform:translate(-50%,15px); +max-width:calc(100% - 32px); +width:max-content; +background:var(--ink); +color:white; +padding:13px 20px; +border-radius:6px; +box-shadow:0 5px 25px #15213725; +font-size:12px; +z-index:100; +opacity:0; +visibility:hidden; +transition:opacity .2s,transform .2s} +.toast.visible{opacity:1; +visibility:visible; +transform:translate(-50%,0)} + +.no-js .copy-button,.no-js .terminal-copy,.no-js .workflow-tabs,.no-js .menu-toggle{display:none} +.no-js .navigation{display:flex; +position:static; +box-shadow:none; +flex-wrap:wrap} +.no-js .header-inner{height:auto; +min-height:85px; +padding-block:15px} +.no-js .workflow-description{margin-bottom:15px} + +/* Hugo renders all localized content before JavaScript runs. */ +[hidden] { display: none !important; } +.no-js .terminal + .terminal { margin-top: 20px; } +.docs-sidebar nav ul { margin: 0; padding: 0; list-style: none; } +.docs-sidebar nav li { margin: 0 0 5px; } +.docs-sidebar nav a { display: block; } +.docs-prose h2 { margin: 48px 0 17px; font-size: 27px; letter-spacing: -.8px; scroll-margin-top: 110px; } +.docs-prose h3 { margin: 28px 0 12px; font-size: 16px; font-weight: 600; } +.docs-prose p, .docs-prose li { color: var(--muted); font-size: 13px; line-height: 1.95; } +.docs-prose p + p { margin-top: 12px; } +.docs-prose a { color: var(--blue); text-decoration: underline; text-underline-offset: 3px; } +.docs-prose :not(pre) > code { background: #eaf0fb; color: #435c8e; padding: 2px 5px; border-radius: 3px; font-size: .85em; } +@media (max-width: 700px) { + .docs-sidebar nav ul { display: flex; flex-wrap: wrap; gap: 7px; } + .docs-sidebar nav li { margin: 0; } + .docs-prose h2 { margin-top: 36px; font-size: 25px; } + .docs-prose p, .docs-prose li { font-size: 12px; } +} +/* Documentation shares the site's typography, controls and spacing. */ +.docs-layout{display:grid; +grid-template-columns:210px minmax(0,1fr); +gap:75px; +padding-block:62px 100px; +max-width:1130px} +.docs-sidebar{position:sticky; +top:125px; +align-self:start} +.docs-sidebar .eyebrow{color:#64738a; +margin-bottom:15px} +.docs-sidebar nav{display:flex; +flex-direction:column; +gap:5px} +.docs-sidebar a{font-size:12px; +color:var(--muted); +padding:10px 12px; +border-left:2px solid var(--line)} +.docs-sidebar a:hover,.docs-sidebar a.current{background:#edf2ff; +border-color:var(--blue); +color:var(--blue)} +.docs-sidebar .sidebar-source{margin-top:25px; +display:block; +border:none; +font-size:10px} +.docs-content{min-width:0} +.docs-content>.eyebrow{color:var(--blue); +margin-bottom:15px} +.docs-content h1{font-size:47px; +letter-spacing:-2.3px; +font-weight:600; +line-height:1.15} +.docs-intro{font-size:15px; +color:var(--muted); +line-height:1.9; +margin-top:20px} +.docs-section{padding-top:48px; +scroll-margin-top:100px} +.docs-section h2{font-size:27px; +letter-spacing:-.8px; +margin-bottom:17px} +.docs-section h3{font-size:16px; +font-weight:600; +margin-block:25px 12px} +.docs-section p,.docs-section li{font-size:13px; +line-height:1.95; +color:var(--muted)} +.docs-section p+p{margin-top:12px} +.docs-section p a{color:var(--blue); +text-decoration:underline; +text-underline-offset:3px} +.docs-section :not(pre)>code,.docs-note code{font-size:.85em; +background:#eaf0fb; +border-radius:3px; +padding:2px 5px; +color:#435c8e} +.docs-note{display:flex; +gap:14px; +align-items:flex-start; +background:#edf3ff; +border:1px solid #d8e3fa; +border-radius:5px; +padding:17px 19px; +margin-block:23px; +color:#516d9d; +font-size:12px; +line-height:1.9} +.docs-note>span:first-child{color:var(--blue); +font-family:var(--mono)} +.code-block{position:relative; +margin-block:17px; +background:#101b2d; +color:#d6e0f2; +border:1px solid #283950; +border-radius:6px; +overflow:hidden} +.code-block-top{display:flex; +align-items:center; +justify-content:space-between; +gap:15px; +padding:7px 13px; +border-bottom:1px solid #2d3a50; +background:#19263b; +color:#aab8cf; +font-family:var(--mono); +font-size:10px; +min-height:38px} +.code-block pre{padding:20px; +font-size:11px; +line-height:2; +max-width:100%; +scrollbar-width:thin} +.code-block .copy-button{color:#aab8cf; +font-size:14px} +.code-block .copy-button:hover{background:#314363; +color:white} +.docs-section details{border:1px solid var(--line); +border-radius:5px; +margin-top:22px; +padding:16px 20px} +.docs-section summary{cursor:pointer; +font-size:12px; +font-weight:600; +line-height:1.8} +.docs-section details[open]>summary{margin-bottom:16px} +.docs-end{border-top:1px solid var(--line); +margin-top:48px; +padding-top:26px; +display:flex; +justify-content:space-between; +gap:20px; +font-size:11px; +color:var(--muted)} +.docs-end a:hover{color:var(--blue)} + +html[lang='zh-CN'] .hero h1{font-size:clamp(50px,5.1vw,72px); +line-height:1.2; +letter-spacing:-2.4px} +html[lang='zh-CN'] h2{line-height:1.35; +letter-spacing:-1px} +html[lang='zh-CN'] .hero-description{max-width:410px} +html[lang='zh-CN'] .navigation{gap:25px} +html[lang='zh-CN'] .community-card h2{font-size:33px} +html[lang='zh-CN'] .eyebrow{letter-spacing:1px} + +@keyframes artifact-arrive{from{opacity:0; +transform:translateY(18px)} +to{opacity:1; +transform:translateY(0)} +} +@keyframes cursor-blink{50%{opacity:0} +} + +@media(min-width:1450px){.hero{min-height:785px} +.hero-art{height:610px} +.artifact-image{top:0} +.hero h1{font-size:85px} +.hero-description{font-size:16px} +} + +@media(max-width:1150px){.container{width:calc(100% - 72px)} +.header-inner{gap:25px} +.navigation{gap:21px; +font-size:11px} +.nav-actions{gap:16px; +margin-left:8px} +.hero{min-height:678px; +padding-block:57px} +.hero h1{letter-spacing:-3.6px} +.hero-art{height:510px; +margin-right:-10px} +.artifact-image{top:42px} +.art-kicker{left:20px; +top:24px; +font-size:7px} +.artifact-badge{right:4px; +bottom:71px} +.art-caption{left:30px; +right:15px; +bottom:35px; +font-size:7px} +.hero-description{font-size:13px; +max-width:355px} +.install-command{gap:8px; +padding-left:10px} +.install-command code{font-size:9px} +.hero-actions{gap:18px} +.text-link{font-size:11px; +gap:9px} +.standards-inner{gap:20px} +.standards-inner>.eyebrow{max-width:112px; +font-size:8px} +.standard-brand{font-size:17px; +gap:10px} +.standard-label{font-size:9px} +.standard-brand small{font-size:8px} +.oci-wordmark{font-size:28px} +.section{padding-block:85px} +h2{font-size:37px} +.section-heading>p{max-width:330px; +font-size:12px} +.feature-grid{gap:16px} +.feature-card{padding:21px 20px} +.feature-card h3{font-size:15px} +.feature-card>p{font-size:11px} +.file-stack>span{font-size:8px; +padding:8px} +.visual-arrow{margin-inline:10px} +.mini-artifact{padding:12px} +.workflow-inner{gap:40px; +grid-template-columns:.95fr 1.05fr} +.workflow-section{padding-block:80px} +.terminal-body{padding:26px 20px} +.terminal pre{font-size:10px} +.ecosystem-section{gap:40px} +.map-node{min-width:119px; +padding:11px 9px; +font-size:9px} +.map-label{font-size:7px} +.registry-card{width:125px} +.map-bridge{min-width:100px} +.community-card{padding:46px 42px} +.community-card h2{font-size:33px} +.community-art{width:225px} +html[lang='zh-CN'] .navigation{gap:18px} +html[lang='zh-CN'] .community-card h2{font-size:30px} +} + +@media(max-width:940px){.container{width:calc(100% - 56px)} +.header-inner{height:74px} +.menu-toggle{display:flex; +flex-direction:column; +justify-content:center; +gap:6px; +background:transparent; +width:40px; +height:40px; +padding:8px} +.menu-toggle>span{width:24px; +height:1.5px; +background:var(--ink); +transition:transform .2s} +.menu-toggle[aria-expanded='true']>span:first-child{transform:translateY(3.75px) rotate(45deg)} +.menu-toggle[aria-expanded='true']>span:last-child{transform:translateY(-3.75px) rotate(-45deg)} +.navigation{display:none; +position:absolute; +top:74px; +left:0; +right:0; +background:var(--paper); +padding:22px 28px; +box-shadow:0 16px 24px #122a4410; +border-bottom:1px solid var(--line); +align-items:stretch; +font-size:14px} +.navigation.open{display:flex; +flex-direction:column; +gap:6px} +.navigation>a{padding:12px 0} +.nav-actions{margin:10px 0 0; +border-top:1px solid var(--line); +padding-top:17px; +gap:25px} +.nav-actions .button{margin-left:auto} +.hero{min-height:640px; +grid-template-columns:1fr .9fr} +.hero h1{font-size:59px; +letter-spacing:-3.4px} +.hero-art{height:465px; +margin-left:-28px} +.artifact-image{top:35px; +width:103%; +left:-3%} +.art-kicker{font-size:6px; +letter-spacing:1px; +left:10px} +.artifact-badge{bottom:64px; +right:0; +padding:11px 13px; +gap:8px} +.badge-cube{width:29px; +height:29px; +font-size:24px} +.artifact-badge strong{font-size:10px} +.artifact-badge div>span{font-size:8px} +.badge-check{margin-left:3px} +.art-caption{left:10px; +font-size:6px; +gap:8px; +bottom:25px} +.hero-description{font-size:12px; +max-width:315px} +.hero-actions{gap:16px} +.button{padding-inline:18px; +font-size:11px; +gap:20px} +.hero-actions .text-link{font-size:10px} +.install-command{width:100%} +.install-command code{font-size:8px} +.hero-footnote{font-size:9px} +.standards-inner{display:grid; +grid-template-columns:repeat(3,1fr); +padding-block:25px; +gap:20px} +.standards-inner>.eyebrow{grid-column:1/-1; +max-width:none; +text-align:center} +.standard-brand{justify-content:center; +font-size:15px} +.standard-brand svg{width:28px; +height:28px} +.standard-label{font-size:8px} +.standard-brand small{font-size:7px} +.section-heading>p{max-width:300px} +.section-heading{gap:30px} +h2{font-size:32px} +.feature-card{padding:18px 15px} +.feature-visual{height:138px; +margin-bottom:20px} +.feature-card h3{font-size:13px} +.feature-card>p{font-size:10px} +.feature-card .inline-link{font-size:9px} +.file-stack>span{font-size:6px; +padding:7px; +gap:5px} +.mini-artifact{padding:10px} +.mini-artifact img{width:29px} +.visual-arrow{margin-inline:8px} +.registry-node{width:35px; +height:42px} +.registry-center{width:45px; +height:54px} +.registry-tag{font-size:6px} +.dashed-connector{width:17px} +.fetch-list>span{font-size:7px; +padding:8px} +.fetch-list i{display:none} +.fetch-chip{font-size:6px} +.workflow-inner{gap:30px} +.workflow-copy>.eyebrow{font-size:7px} +.workflow-description{font-size:11px} +.workflow-tab{padding:14px 10px; +gap:12px} +.workflow-tab strong{font-size:11px} +.workflow-tab small{font-size:9px} +.terminal-body{padding:25px 16px; +min-height:330px} +.terminal-comment{font-size:8px} +.terminal pre{font-size:9px} +.terminal-result{font-size:8px} +.terminal-bottom{padding:11px 12px; +font-size:6px} +.terminal-copy{font-size:7px; +gap:5px} +.ecosystem-section{grid-template-columns:1fr; +gap:65px} +.ecosystem-copy{max-width:570px} +.ecosystem-copy>p:not(.eyebrow){max-width:510px} +.ecosystem-map{width:100%; +max-width:590px; +margin-inline:auto} +.map-node{min-width:165px; +font-size:12px} +.map-label{font-size:8px} +.registry-card{width:175px; +height:170px} +.community-art{width:180px} +.community-card{padding:38px 30px} +.community-card h2{font-size:28px} +.community-card p:not(.eyebrow){font-size:10px} +.community-actions{gap:18px} +.community-actions .text-link{font-size:10px} +.community-mark{width:90px; +height:100px} +.community-mark img{width:51px} +.orbit-one{width:155px; +height:155px} +.orbit-two{width:235px; +height:235px} +.orbit-three{width:315px; +height:315px} +.node-one{left:-5px; +top:36px} +.node-two{right:-5px; +top:55px} +.node-three{right:25px; +bottom:15px} +.footer-links{margin-right:0; +gap:55px} +.docs-layout{gap:35px; +grid-template-columns:160px minmax(0,1fr)} +.docs-sidebar a{font-size:11px; +padding:10px 7px} +.docs-content h1{font-size:40px} +.docs-intro{font-size:13px} +.docs-section h2{font-size:25px} +html[lang='zh-CN'] .hero h1{font-size:54px} +html[lang='zh-CN'] .community-card h2{font-size:24px} +} + +@media(max-width:700px){html{scroll-padding-top:94px} +.container{width:calc(100% - 40px)} +.brand{font-size:25px} +.brand img{width:27px} +.header-inner{height:69px} +.navigation{top:69px; +padding:18px 20px} +.hero{grid-template-columns:1fr; +padding-top:44px; +padding-bottom:24px; +gap:0; +min-height:auto} +.hero-eyebrow{font-size:8px; +margin-bottom:22px} +.hero h1{font-size:clamp(55px,11vw,74px); +line-height:1.08; +letter-spacing:-3.8px} +.hero-description{font-size:13px; +line-height:1.9; +max-width:440px; +margin-top:24px} +.hero-actions{gap:23px; +margin-top:24px} +.button{min-height:46px; +font-size:11px} +.hero-actions .text-link{font-size:11px} +.install-command{margin-top:27px; +width:fit-content; +max-width:100%; +padding:5px 6px 5px 12px} +.install-command code{font-size:9px} +.hero-footnote{font-size:9px} +.hero-art{height:auto; +aspect-ratio:1.08; +margin:19px 0 0; +max-width:510px; +width:100%; +justify-self:center} +.artifact-image{width:102%; +left:-1%; +top:0} +.art-kicker{top:11px; +left:18px; +font-size:6px} +.artifact-badge{bottom:32px; +right:17px} +.art-caption{bottom:0; +left:30px; +right:30px; +font-size:6px} +.art-grid{inset:0} +.standards-inner{gap:22px 12px; +padding-block:24px} +.standard-brand{gap:6px; +font-size:13px} +.standard-brand svg{width:21px} +.standard-brand small{font-size:7px; +line-height:1.7; +max-width:99px} +.standard-label{font-size:7px; +max-width:75px; +line-height:1.5} +.oci-wordmark{font-size:23px} +.oci-wordmark>span{display:none} +.section{padding-block:66px} +.section-heading{display:block; +margin-bottom:30px} +.section .eyebrow{font-size:8px; +margin-bottom:15px} +h2{font-size:33px; +letter-spacing:-1.4px} +.section-heading>p{max-width:470px; +font-size:12px; +margin-top:21px} +.feature-grid{grid-template-columns:1fr; +gap:17px} +.feature-card{padding:20px 25px 26px} +.feature-visual{height:164px; +max-width:320px; +width:100%; +align-self:center; +margin-bottom:22px} +.feature-card h3{font-size:18px} +.feature-card>p{font-size:12px} +.feature-card .inline-link{font-size:11px; +margin-top:19px} +.file-stack>span{font-size:9px; +gap:9px; +padding:9px 12px} +.visual-arrow{margin-inline:24px} +.mini-artifact{padding:17px} +.mini-artifact img{width:36px} +.registry-node{width:52px; +height:54px} +.registry-center{width:65px; +height:67px} +.dashed-connector{width:31px} +.registry-tag{font-size:9px} +.fetch-list>span{font-size:10px} +.fetch-list i{display:block} +.fetch-chip{font-size:8px} +.workflow-section{padding-block:62px} +.workflow-inner{grid-template-columns:1fr; +gap:24px} +.workflow-copy>.eyebrow{font-size:8px} +.workflow-description{font-size:12px; +max-width:400px; +margin-top:20px} +.workflow-tabs{margin-top:25px} +.workflow-tab{padding:15px; +gap:18px} +.workflow-tab strong{font-size:12px} +.workflow-tab small{font-size:10px} +.terminal-wrap{padding-top:0} +.terminal-body{padding:25px 19px; +min-height:327px} +.terminal pre{font-size:10px; +min-height:110px} +.terminal-comment{font-size:8px} +.terminal-result{font-size:9px} +.terminal-bottom{font-size:7px; +padding:11px 14px} +.terminal-copy{font-size:8px} +.terminal-note{font-size:8px} +.ecosystem-section{gap:55px} +.ecosystem-copy>p:not(.eyebrow){font-size:12px} +.ecosystem-map{max-width:440px} +.map-node{min-width:120px; +padding:10px 8px; +font-size:9px} +.map-label{font-size:6px; +top:-25px; +letter-spacing:.6px} +.map-bridge{min-width:82px} +.map-bridge::after{width:18px; +height:140px; +top:calc(50% - 70px)} +.map-hub{width:61px; +height:70px; +gap:4px} +.map-hub img{width:29px; +height:32px} +.map-hub strong{font-size:11px} +.map-hub-caption{font-size:6px; +letter-spacing:.5px; +top:calc(100% + 11px)} +.registry-card{width:117px; +height:142px} +.registry-card strong{font-size:10px} +.registry-card small{font-size:8px} +.community-section{padding-bottom:64px} +.community-card{padding:32px 26px; +display:block} +.community-card .eyebrow{font-size:7px; +margin-bottom:16px} +.community-card h2{font-size:29px; +letter-spacing:-1.2px} +.community-card p:not(.eyebrow){font-size:11px; +line-height:1.9; +max-width:300px; +margin-top:15px} +.community-art{display:none} +.community-actions{align-items:flex-start; +flex-direction:column; +gap:21px; +margin-top:23px} +.community-actions .text-link{font-size:11px} +.site-footer{padding-top:38px} +.footer-top{flex-direction:column; +gap:35px; +padding-bottom:32px} +.footer-links{gap:70px} +.footer-links>div{font-size:10px; +gap:12px} +.footer-bottom{flex-direction:column; +align-items:flex-start; +gap:15px; +padding-block:20px; +font-size:8px} +.docs-layout{display:block; +padding-block:28px 60px} +.docs-sidebar{position:static; +margin-bottom:35px} +.docs-sidebar .eyebrow,.docs-sidebar .sidebar-source{display:none} +.docs-sidebar nav{flex-direction:row; +flex-wrap:wrap; +gap:7px} +.docs-sidebar a{border:1px solid var(--line); +border-radius:4px; +padding:7px 10px; +font-size:10px} +.docs-content h1{font-size:37px} +.docs-content>.eyebrow{font-size:9px} +.docs-intro{font-size:13px} +.docs-section{padding-top:36px} +.docs-section h2{font-size:25px} +.docs-section p,.docs-section li{font-size:12px} +.docs-note{font-size:11px; +padding:13px 15px; +gap:10px} +.code-block pre{padding:15px; +font-size:10px} +.docs-end{font-size:9px} +html[lang='zh-CN'] .hero h1{font-size:clamp(51px,10vw,65px); +letter-spacing:-2px} +html[lang='zh-CN'] .community-card h2{font-size:26px} +} + +@media(max-width:370px){.container{width:calc(100% - 32px)} +.hero h1{font-size:51px; +letter-spacing:-3px} +.install-command{gap:6px; +padding-left:8px} +.install-command code{font-size:8px} +.hero-actions{gap:15px} +.hero-actions .text-link{font-size:10px} +.map-node{min-width:107px; +font-size:8px} +.map-bridge{min-width:71px} +.registry-card{width:103px} +.registry-tag{font-size:8px} +.standards-inner{gap:16px 8px} +.standard-brand{font-size:11px} +.standard-brand svg{width:18px} +.standard-label{font-size:6px} +.standard-brand small{font-size:6px} +.community-card{padding:27px 21px} +.community-card h2{font-size:26px} +.terminal pre{font-size:9px} +.terminal-result{font-size:8px} +.terminal-comment{font-size:7px} +html[lang='zh-CN'] .hero h1{font-size:47px} +} + +@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto} +*,*::before,*::after{animation:none!important; +transition:none!important} +} + +@media(max-width:940px){.no-js .header-inner{flex-direction:column; +align-items:flex-start; +gap:16px} +.no-js .navigation{padding:0; +border:0; +gap:16px; +font-size:11px} +.no-js .navigation>a{padding:0} +.no-js .nav-actions{margin:0; +padding:0; +border:0} +.no-js .site-header{position:static} +} + +@media print{.site-header,.site-footer,.docs-sidebar,.toast,.copy-button,.menu-toggle,.language-toggle{display:none} +.docs-layout{display:block; +width:100%; +padding:0} +.code-block{break-inside:avoid} +.docs-section{break-inside:avoid} +.button{box-shadow:none} +body{background:white} +}