--- url: https://docs.w3id.org/overview.md --- # What is w3id.org? w3id.org is a permanent URL redirection service for the Web. It is operated by the [W3C Permanent Identifier Community Group](https://www.w3.org/community/perma-id/). The service does one thing. You claim a path under `https://w3id.org/`, and you say where requests for that path should be sent. When somebody resolves your identifier, w3id.org answers with an HTTP redirect to the location you nominated. ``` GET https://w3id.org/my-project/vocab │ ▼ w3id.org ──► 302 Found Location: https://my-project.example.org/2026/vocab ``` Think of it as a [switchboard](https://en.wikipedia.org/wiki/Telephone_switchboard). It connects a request for information to wherever that information currently lives, and it can be reconfigured when the information moves. The identifier that people wrote into their data, their code, and their published papers never has to change. ## Why it exists Applications that deal with [Linked Data](https://en.wikipedia.org/wiki/Linked_data) need URLs that are extremely stable. A vocabulary term, an ontology IRI, or a JSON-LD context URL gets embedded in documents and datasets that outlive the web server that first published them. Universities reorganise. Companies get acquired. Projects get funded for three years and then stop. An identifier under w3id.org survives all of that, because the identifier is decoupled from the hosting. See [Purpose](./purpose) for more. ## How a change gets made Everything the service does is defined by files in a public Git repository at [github.com/perma-id/w3id.org](https://github.com/perma-id/w3id.org). Each identifier is a directory under `ids/` containing an `.htaccess` file with Apache rewrite rules. To add or change an identifier, you open a pull request against that repository. A maintainer reviews it and merges it, and the change is deployed to the service. See [Creating an identifier](/guides/create-an-id). ## Who runs it A consortium of organisations has pledged responsibility for keeping the service running: * [Digital Bazaar](https://www.digitalbazaar.com/) * [3 Round Stones](http://3roundstones.com/) * [OpenLink Software](https://www.openlinksw.com/) * [Applied Testing and Technology](https://www.aptest.com/) * [Bosatsu Consulting](https://bosatsu.net/) * [KurrawongAI](https://kurrawong.ai) They handle all administrative tasks associated with operating the service. The social contract between them gives each organisation full access to everything needed to run the site, so that several of them could fail, lose interest, or become unreachable for long periods without affecting the service. ### Joining the consortium Introduce yourself on the mailing list first (see below) and say what your organisation would bring. Then open an issue on the [issue tracker](https://github.com/perma-id/w3id.org/issues) titled *Seeking to join the W3ID Consortium* with your details. ## Community Discussion happens on the [public-perma-id@w3.org mailing list](https://lists.w3.org/Archives/Public/public-perma-id/). That is the right place to raise questions about the service, propose policy changes, or discuss a use case that the [scope](./scope) does not obviously cover. ## Disclaimer The letters "w3" in the domain name stand for "World Wide Web". Other than hosting the community group, the **World Wide Web Consortium (W3C) is not involved in the support or management of this service in any way**. An identifier under w3id.org carries no endorsement from the W3C, and is not a W3C standard, recommendation, or publication. --- --- url: https://docs.w3id.org/overview/purpose.md --- # Purpose ## The problem this service solves A URL is two things at once: a *name* for something, and an *address* telling you where to fetch it. For most of the Web that conflation is fine. For Linked Data it is a serious problem. When you publish a vocabulary, the IRIs of its terms are copied into other people's data. A JSON-LD context URL gets baked into documents that are signed, archived, and exchanged for years. A property IRI ends up in triple stores around the world. Those IRIs are *names*, and names are supposed to be stable. But the address half of the URL is not stable at all. It points at a hostname that belongs to a particular organisation, on a particular server, under a particular directory layout, subject to a particular funding cycle. Three years later: * the research group's grant ends and `project.cs.example.edu` disappears; * the company is acquired and every URL moves to a new domain; * somebody reorganises the website and `/ontologies/` becomes `/vocab/`; * the maintainer leaves and nobody renews the domain. Every dataset that referenced those IRIs now contains dead links, and there is nothing the people holding that data can do about it. ## What w3id.org does about it w3id.org gives you a name whose address you can change. You publish your vocabulary at `https://w3id.org/my-vocab/`. That is what goes into other people's data. Behind it, w3id.org holds a redirect pointing at wherever the content actually lives today. When the content moves, you open a pull request that changes one line, and every existing reference keeps resolving. The identifier is decoupled from the hosting. That is the whole idea. ## The commitment Identifiers on this service are intended to last **as long as the Web lasts** — decades, and preferably longer. Two things follow from that, and both of them place obligations on you as well as on the service: **The service will not casually delete your identifier.** Once an identifier is in use, removing it breaks other people's data. Identifiers are effectively permanent even when the project behind them has stopped. **You should not claim an identifier you are not prepared to steward.** A directory under `ids/` is a commitment on the order of twenty years, not a convenient short link for a demo. If the redirect target dies and nobody maintains it, the identifier becomes a permanent broken link with your project's name on it. If the destination for a *widely used* identifier fails badly enough to become a danger or a major inconvenience to the Web, the community may mirror the content and repoint the redirect to restore service. That is a last resort for the popular cases, not a general safety net. ## Security The service operates in **HTTPS-only** mode, end to end. There is no plaintext HTTP option, and requests are not downgraded. This is deliberate: it lets the service be used by Linked Data applications that require high assurance, including those in the financial, medical, and public infrastructure sectors, where a tampered redirect would be a meaningful attack. Your redirect targets should be HTTPS too — see [`htaccess/https-target`](/rules/htaccess/https-target). A secure redirect to an insecure destination protects nobody. ## What this is not Read [Scope](./scope) next. The single most common reason a pull request gets rejected is that the contributor wanted the service to do something it does not do. --- --- url: https://docs.w3id.org/overview/scope.md --- # Scope This page exists because most rejected pull requests are not *wrong*, they are *out of scope*. The contributor built something reasonable for a service that this is not. ## What w3id.org is for **Stable identifiers that redirect somewhere you control.** Concretely, the service is a good fit when: * You are publishing a **vocabulary, ontology, or schema** and need permanent IRIs for its terms. * You need a permanent **JSON-LD context URL**, or a stable IRI for a credential type, a specification, or a protocol. * You want a **namespace** under which your project can mint identifiers, e.g. `https://w3id.org/my-project/...`, that outlives your current hosting. * You need **content negotiation** in front of an ontology, so that a browser gets HTML and a triple store gets Turtle, from the same IRI. See [Content negotiation](/guides/content-negotiation). * You are an individual who wants a stable identifier for yourself, under the shared [`/people/`](https://github.com/perma-id/w3id.org/tree/master/ids/people) namespace. In all of these, w3id.org holds a redirect and nothing else. The content lives on your infrastructure. ## What w3id.org is not for ### It is not a file host **Do not commit your ontology, schema, context, or documentation to this repository.** Not as `.ttl`, `.owl`, `.rdf`, `.jsonld`, `.json`, `.html`, or anything else. This is the single most common out-of-scope pull request. It gets rejected every time, and it is not a matter of size — a one-line context file is as out of scope as a fifty-megabyte ontology. The repository holds redirect rules and contact information. That is the whole inventory. Host the files yourself: a project website, GitHub Pages, an institutional repository, Zenodo, a purpose-built vocabulary server. Then point your w3id.org identifier at it. See [`files/only-allowed-names`](/rules/files/only-allowed-names). ### It is not a CDN or a general-purpose URL shortener The service is not there to make your links shorter, to sit in front of your website for performance, or to give a marketing campaign a tidy URL. It exists so that *identifiers embedded in data* keep resolving. If nothing is going to embed the identifier, you probably do not need one. ### It is not a place to reserve names Claiming a directory is a long-term commitment, not a land grab. Requests are declined for names that are: * **too generic** — a single common English word, or a term the whole community would plausibly need; * **confusing** — close enough to an existing identifier or a well-known organisation to mislead; * **inappropriate or offensive**; * **speculative** — claimed with no concrete project behind them, or claimed in bulk. Do not squat on names for projects that do not exist yet, and do not claim a top-level directory when a path under an existing namespace would do. ### It is not a dynamic application platform There is no application code, no database, no server-side processing you can extend, no authentication, no access control, no per-user logic. `.htaccess` rewrite rules are the only mechanism available, and only the subset of them that produces redirects. Specifically, you cannot use this service to: * proxy or mirror content; * vary a response by who is asking, or by anything other than the request URL and headers; * add authentication or restrict access to an identifier; * run any code. ### It is not a place for project documentation The `README.md` in your identifier directory is for **identifier and maintainer information** — what the identifier is, who looks after it, how to reach them. It is not your project's README, not a user manual, and not a specification. Keep it short and link out to the real documentation. ## Naming There is no formal naming policy, and no published list of reserved names. The current practice: * Claim a **top-level directory** for your project and mint second-level identifiers under it: `https://w3id.org/PROJECT-ID/SUB-ID`. * Shared top-level namespaces exist for some categories, e.g. `https://w3id.org/people/PERSON-ID` for individuals. Use them where they apply rather than claiming a new top level. * Prefer lowercase directory names. See [`tree/no-case-collision`](/rules/tree/no-case-collision) — this is not just aesthetics, case collisions genuinely break the repository on macOS and Windows. Maintainers may decline a name for any of the reasons above. If you are unsure whether a name will be acceptable, ask first — on the [mailing list](https://lists.w3.org/Archives/Public/public-perma-id/) or as an [issue](https://github.com/perma-id/w3id.org/issues). Either is much cheaper than finding out in a pull request review. ## Still not sure? Ask before you write any code, on the [public-perma-id@w3.org mailing list](https://lists.w3.org/Archives/Public/public-perma-id/) or as an [issue](https://github.com/perma-id/w3id.org/issues). Describe what you want to identify and where the content will live, and somebody will tell you whether this is the right tool. --- --- url: https://docs.w3id.org/guides.md --- # Guides Task-oriented walkthroughs. If you are looking for a specific rule and why it exists, see the [rule catalogue](/rules/) instead. ## [Creating an identifier](./create-an-id) The main guide. Picking a name, what your directory needs to contain, writing the redirect, and getting the pull request merged. Start here if you have never contributed to this repository. ## [Writing .htaccess rules](./htaccess) What an `.htaccess` file is, how `RewriteRule` works, and the handful of things about the per-directory context that trip almost everyone up the first time. ## [Content negotiation](./content-negotiation) Serving HTML to browsers and RDF to machines from a single ontology IRI, plus version-aware IRIs and CORS. Read this if you are publishing a vocabulary. ## [Maintaining an identifier](./maintain-an-id) Changing a redirect that people already depend on, without breaking them. Transferring maintainership, retiring an identifier, and why you should not reuse an old one. ## [Testing your changes](./testing) How to run the automated checks yourself, and how to confirm a redirect actually goes where you meant it to — which is the part no checker can do for you. ## [Running a local server](./local-server) How to serve the `ids/` directory with a real Apache — under Docker, natively, or as an ordinary user on a machine where you cannot install one — so you can watch what your rules actually do before anyone reviews them. ## Before you open a pull request Whatever you are doing, these are worth a minute: * Your change is a redirect plus minimal information about the project and its maintainers, with no hosted content — [Scope](/overview/scope). * Your redirect target is live, HTTPS, and returns what you expect — [`htaccess/https-target`](/rules/htaccess/https-target), [Testing](./testing). * Your `.htaccess` has no syntax errors. A bad rule returns **500 for the whole directory**, not just for the rule — [Rules](/rules/). * Your commit message says what changed and which project it belongs to. * Your maintainer contact includes a GitHub username — [`meta/maintainer-github-username`](/rules/meta/maintainer-github-username). --- --- url: https://docs.w3id.org/guides/create-an-id.md --- # Creating an identifier This guide takes you from "I want a permanent URL" to a merged pull request. Before you start, make sure w3id.org is actually the right tool — read [Scope](/overview/scope). The most common reason a pull request is rejected is that it asked the service to do something it does not do. ## What you are actually creating An identifier is a **directory under `ids/`** in the [perma-id/w3id.org](https://github.com/perma-id/w3id.org) repository, containing an `.htaccess` file with Apache rewrite rules. ``` ids/ └── my-project/ ├── .htaccess redirect rules — the part that does the work └── README.md identifier and maintainer information (optional) ``` The directory name is the identifier: `ids/my-project/` becomes `https://w3id.org/my-project/`. That is the entire structure. Nothing else belongs in that directory — see [`files/only-allowed-names`](/rules/files/only-allowed-names). A README in any format GitHub renders is accepted, but `README.md` is the convention and what you should reach for. See [`files/prefer-readme-md`](/rules/files/prefer-readme-md). ## 1. Choose a name Read the [naming section of Scope](/overview/scope#naming) first. In short: * Claim **one top-level directory** for your project and put everything under it. Do not claim several. * Use **lowercase**, digits, and hyphens. See [`tree/no-case-collision`](/rules/tree/no-case-collision). * Avoid generic words, and avoid anything that could be confused with an existing identifier or a well-known organisation. * For a personal identifier, use the shared [`/people/`](https://github.com/perma-id/w3id.org/tree/master/ids/people) namespace rather than a new top-level directory. Check the name is free by browsing [`ids/`](https://github.com/perma-id/w3id.org/tree/master/ids) — and check for **case variants** too, because `ids/Foo/` and `ids/foo/` cannot coexist. If you are unsure whether a name will be acceptable, ask on the [mailing list](https://lists.w3.org/Archives/Public/public-perma-id/) before writing anything. That is much cheaper than a rejected pull request. ## 2. Have your redirect target ready Your content needs to already be live somewhere you control, over HTTPS, before you open the pull request. Reviewers check this. An identifier pointing at a URL that does not resolve yet will be asked to wait. An identifier pointing at a URL that never resolves is a permanent broken link with your project's name on it. ## 3. Fork and create the directory Fork [perma-id/w3id.org](https://github.com/perma-id/w3id.org) on GitHub and create `ids//.htaccess`. ::: warning Creating a dot-file in the GitHub web interface The GitHub web editor will not let you create a file whose name *starts* with a dot by typing `.htaccess` into an empty filename box. The workaround: use **Add file → Create new file**, and type the **whole path** into the filename box — ``` ids/my-project/.htaccess ``` Typing the path with the directories in front makes GitHub accept the leading dot. Do **not** work around this by naming the file `htaccess.txt` and renaming it later. Correcting that mistake after the fact is one of the more common follow-up commits in this repository. See [`files/only-allowed-names`](/rules/files/only-allowed-names). ::: ## 4. Write the redirect The smallest thing that works — everything under your identifier goes to one place: ```apache # # /my-project/ # # https://w3id.org/my-project/ and everything under it redirects to # https://my-project.example.org/ # # ## Contact # This space is administered by: # # Firstname Lastname # firstname@example.org # GitHub username: exampleuser RewriteEngine on RewriteRule ^(.*)$ https://my-project.example.org/$1 [R=302,L] ``` That is a complete, valid identifier. Note what is *not* there: no `Options` line, no `RewriteBase`, no `AddType`, no `RewriteCond`. Do not add directives you do not need — see [`htaccess/no-options-directive`](/rules/htaccess/no-options-directive). If you want everything to land on a single page rather than preserving the path: ```apache RewriteEngine on RewriteRule ^ https://my-project.example.org/ [R=302,L] ``` For anything more involved — matching specific sub-paths, handling query strings, serving different formats to browsers and machines — read [Writing .htaccess rules](./htaccess) and [Content negotiation](./content-negotiation). ### Choosing the redirect status code | Code | Use it for | | --- | --- | | **302** Found | The default. Plain redirects, grouping, anything you might change later. | | **303** See Other | Content-negotiated ontology IRIs, where the identifier names a *concept* and you are redirecting to a *document about it*. | | **307** Temporary Redirect | When the request method must be preserved. Rare here. | | **301** Moved Permanently | Avoid. | **Avoid 301.** Browsers and caches honour it indefinitely, and many will never re-check. If you get a 301 wrong, you cannot take it back for the users who already cached it — and this is a service designed for identifiers that live for decades. Use 302 unless you have a specific reason not to. ## 5. Add maintainer contact information Every identifier needs a way to reach whoever is responsible for it. Put it in `.htaccess` comments, in a `README.md`, or both — either is fine. It must include a **GitHub username**. This is the most frequent single review comment on this repository. Without it, a reviewer cannot tell whether a future pull request touching your directory is legitimate, and cannot tag you to approve it. See [`meta/maintainer-github-username`](/rules/meta/maintainer-github-username). A `README.md` is optional. If you add one, keep it to identifier and maintainer information and link out to your real documentation: ```markdown # /my-project/ Permanent identifiers for the My Project vocabulary. `https://w3id.org/my-project/` redirects to . Documentation: ## Contact - Firstname Lastname - - GitHub: [@exampleuser](https://github.com/exampleuser) ``` ::: tip Write the contact block as a list Contact blocks are usually written as a stack of lines glued together with invisible markup — two trailing spaces, a trailing backslash, or `
`. Use a bulleted list instead, as above. Two reasons. This repository trims trailing whitespace, so the two-space style silently stops working. And with any of them, the next person to edit the block cannot see what is holding it apart, so deleting a marker reflows three lines into one paragraph without anyone noticing in the diff. See [`markdown/prefer-list-over-line-breaks`](/rules/markdown/prefer-list-over-line-breaks). ::: ## 6. Test it Do not skip this. Automated checks will catch a malformed rule — a syntax error in `.htaccess` returns **500 for every URL in that directory**, not just the one you got wrong — but nothing can tell you whether the redirect goes where you actually meant it to. Run the checks and resolve the identifier yourself: ```sh (cd tools/check && npm ci) # once node tools/check/bin/w3id-check.js ``` See [Testing your changes](./testing) for the full recipe, and [Running a local server](./local-server) for how to run the rules against a real Apache on your own machine. ## 7. Open the pull request * **One identifier per pull request, or as few as practical.** Do not bundle unrelated changes. * **Squash your commits.** A new identifier should be one commit. If you are not comfortable squashing, tick the box in the pull request template and a maintainer will squash when merging — but you will then need to [resync your fork](/faq#do-i-have-to-squash-my-commits) before your next contribution. * **Write a real commit message.** Include your project name. `Update .htaccess` tells a reviewer nothing, and it is already the single most common commit message in this repository by a wide margin. Something like `my-project: add redirect for vocabulary namespace` is what you want. * **Only touch your own directory.** Do not modify the repository root `README.md`, other people's identifiers, or anything under `ids/` that is not yours. See [`tree/only-own-identifier`](/rules/tree/only-own-identifier). ### Checklist * \[ ] The directory is under `ids/`, lowercase, and the name is free * \[ ] The file is named exactly `.htaccess` * \[ ] The redirect target is live and uses HTTPS * \[ ] No content files are committed — redirect rules plus minimal project and maintainer information * \[ ] Maintainer contact includes a GitHub username * \[ ] The rules have been tested * \[ ] Commits are squashed and the message is descriptive * \[ ] No files outside your identifier directory are modified ## 8. After it is merged A maintainer reviews and merges the pull request, and the change is deployed to the service. It is normally live shortly afterwards. Check it: ```sh curl -sIL https://w3id.org/my-project/ ``` If it does not work, open an issue — do not open a second pull request changing the same thing until you know what went wrong. ## The alternative: ask by email If Git and GitHub are unfamiliar and you do not want to learn them for this, you can send a request to the [public-perma-id@w3.org mailing list](https://lists.w3.org/Archives/Public/public-perma-id/) instead. Include: * the w3id.org URL you want, * the URL it should redirect to, * the HTTP status code you want, * your contact details, including a GitHub username if you have one. An administrator will create the redirect. This is slower than a pull request, but it is a perfectly legitimate route. If you would like to learn the pull request workflow, GitHub's own documentation covers it: [Forking a repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) and [Creating a pull request from a fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork). --- --- url: https://docs.w3id.org/guides/htaccess.md --- # Writing .htaccess rules The `.htaccess` file in your identifier directory is what makes the redirect happen. This page covers the syntax you need and, more importantly, the few things about the **per-directory context** that catch almost everyone out. If you just want a plain redirect, [Creating an identifier](./create-an-id) has the two-line version and you do not need this page. ## What an .htaccess file is `.htaccess` is a per-directory configuration file read by the Apache HTTP Server. When a request comes in for a path, Apache looks for `.htaccess` files along that path and applies the directives it finds. On w3id.org, the directives available to you are effectively limited to [`mod_rewrite`](https://httpd.apache.org/docs/current/mod/mod_rewrite.html) (`RewriteEngine`, `RewriteRule`, `RewriteCond`), plus `AddType` and `Header` for content negotiation. There is no application code behind it. ::: danger A syntax error takes out the whole directory If Apache cannot parse your `.htaccess`, it returns **500 Internal Server Error** for *every* URL under that directory — not just for the broken rule. This is why [testing](./testing) matters, and why a stray space inside a flag list is a genuine outage rather than a typo. ::: ## The basic shape ```apache RewriteEngine on RewriteRule ^ https://example.com/ [R=302,L] ``` `RewriteEngine on` switches the rewrite engine on for this directory. Without it, none of your rules run at all — and it is not inherited in a way you should rely on. Put it in every `.htaccess` you write. `RewriteRule` is the rule itself. Its syntax is: ``` RewriteRule Pattern Substitution [Flags] ``` ## Pattern A [Perl-compatible regular expression](https://httpd.apache.org/docs/current/rewrite/intro.html#regex) matched against the requested path. **The single most important thing to understand:** in a per-directory `.htaccess`, the pattern is matched against the path **relative to the directory containing the file**, with no leading slash. So in `ids/my-project/.htaccess`, for a request to `https://w3id.org/my-project/vocab/Thing`: | What the pattern sees | `vocab/Thing` | | --- | --- | | ❌ `^/my-project/vocab/Thing$` | never matches — wrong prefix *and* leading slash | | ❌ `^/vocab/Thing$` | never matches — leading slash | | ✅ `^vocab/Thing$` | matches | Getting this wrong is silent. The rule simply never fires, and you get a 404 instead of a redirect. See [`htaccess/pattern-relative-to-dir`](/rules/htaccess/pattern-relative-to-dir). For a request to the identifier root itself — `https://w3id.org/my-project/` — the pattern sees the **empty string**, which is why `^$` and `^` both work as "the identifier root". ### Regex essentials | | | | --- | --- | | `^` | start of the string | | `$` | end of the string | | `.` | any single character | | `*` | zero or more of the preceding | | `+` | one or more of the preceding | | `?` | zero or one of the preceding (makes it optional) | | `[^/]` | any character except `/` | | `(...)` | a capture group, referenced as `$1`, `$2`, … in the substitution | | `\.` | a **literal** dot | Three traps worth naming explicitly, because they recur: **Escape literal dots.** `^vocab.ttl$` also matches `vocabXttl`. Write `^vocab\.ttl$`. See [`htaccess/escape-literal-dots`](/rules/htaccess/escape-literal-dots). **Anchor your patterns.** `^vocab` matches `vocabulary-of-doom` too. Unless you mean a prefix match, end the pattern with `$`. See [`htaccess/anchor-patterns`](/rules/htaccess/anchor-patterns). **Watch greedy captures.** `^(.+)/?$` does not do what it looks like it does — `.+` is greedy and swallows the trailing slash, so `$1` ends up containing it and you get a doubled slash in the output. Use `^([^/]+)/?$`. See [`htaccess/no-greedy-capture`](/rules/htaccess/no-greedy-capture). ## Substitution The replacement. On w3id.org this is almost always an absolute URL to somewhere outside w3id.org. Capture groups from the pattern are available as `$1`, `$2`, and so on: ```apache # /my-project/anything -> https://example.org/base/anything RewriteRule ^(.*)$ https://example.org/base/$1 [R=302,L] ``` Watch the slashes. If your pattern captures a leading slash and your substitution also has one, you get `https://example.org/base//anything`, which many servers treat as a different path. See [`htaccess/no-double-slash`](/rules/htaccess/no-double-slash). ## Flags A comma-separated list in square brackets. These are the ones that come up here; the full list is in the [Apache documentation](https://httpd.apache.org/docs/current/rewrite/flags.html). | Flag | Meaning | | --- | --- | | `R=302` | Issue an HTTP redirect with this status code | | `L` | Last — stop processing rules if this one matches | | `NC` | No case — match the pattern case-insensitively | | `NE` | No escape — do not URL-escape special characters in the output | | `QSA` | Query string append — keep the original query string | | `OR` | Combine with the next `RewriteCond` using OR instead of AND | ::: danger No spaces inside the brackets ```apache RewriteRule ^ https://example.com/ [R=302, L] # ← 500 Internal Server Error RewriteRule ^ https://example.com/ [R=302,L] # ← correct ``` Apache does not tolerate whitespace in the flag list. This is a real, recurring cause of outages in this repository. See [`htaccess/no-flag-whitespace`](/rules/htaccess/no-flag-whitespace). ::: ::: danger And a space before them ```apache RewriteRule ^ https://example.com/[R=302,L] # ← no flags at all RewriteRule ^ https://example.com/ [R=302,L] # ← correct ``` Without the space Apache reads the brackets as part of the target, so no flag applies. The redirect still happens, which is why this survives review: it goes to the target with `%5BR=302,L%5D` on the end, at 302 whatever status you asked for. See [`htaccess/space-before-flags`](/rules/htaccess/space-before-flags). ::: Always use `L` on a redirect rule. Without it Apache keeps evaluating subsequent rules, which at best wastes work and at worst produces a redirect loop. ## Common patterns ### Everything to one destination ```apache RewriteEngine on RewriteRule ^ https://example.org/landing [R=302,L] ``` ### Preserve the path ```apache RewriteEngine on RewriteRule ^(.*)$ https://example.org/base/$1 [R=302,L] ``` ### The root, and sub-paths, differently ```apache RewriteEngine on # https://w3id.org/my-project/ -> the project home page RewriteRule ^$ https://example.org/ [R=302,L] # https://w3id.org/my-project/anything-else -> under /vocab/ RewriteRule ^(.+)$ https://example.org/vocab/$1 [R=302,L] ``` Order matters: the more specific rule goes first, and `L` stops the rest from running. ### An optional trailing path ```apache # matches /my-project/taxonomy AND /my-project/taxonomy/anything RewriteRule ^taxonomy(/.*)?$ https://example.org/taxonomy$1 [R=302,L] ``` Note `(/.*)?$` — with a bare `(/.*)$` the group is required, and `/my-project/taxonomy` with no trailing slash does not match. ### Dispatch on a file extension ```apache RewriteEngine on RewriteRule ^vocab\.(ttl|jsonld|rdf)$ https://example.org/vocab.$1 [R=303,L] ``` ### Query strings The query string is **not** part of what the pattern matches. It is passed through to the substitution automatically, as long as your substitution does not contain a `?` of its own. If it does — because you are adding parameters yourself — the incoming query string is dropped instead. `QSA` appends it to yours rather than replacing it: ```apache RewriteRule ^vocab$ https://example.org/get?format=ttl [QSA,R=302,L] ``` To match on the query string, you need `RewriteCond` with `%{QUERY_STRING}`: ```apache RewriteCond %{QUERY_STRING} (.*) RewriteRule ^ https://example.org/page.php?%1 [R=302,L] ``` Captures from a `RewriteCond` are referenced with `%1`, not `$1` — `$` is for captures from the `RewriteRule` pattern. A trailing `?` on the substitution discards the original query string instead of appending it. ## RewriteCond `RewriteCond` adds a condition to the `RewriteRule` that immediately follows it. Several conditions can stack; by default they are ANDed, and `[OR]` changes that. ``` RewriteCond TestString CondPattern [Flags] ``` The most common use on this service is inspecting the `Accept` header for content negotiation, which has its own page: [Content negotiation](./content-negotiation). ## Things not to add Contributors routinely copy directives from other identifiers without needing them. Each of these has caused a real problem here: | Directive | Why not | | --- | --- | | `Options +FollowSymLinks`, `Options -Indexes`, `Options -MultiViews` | Usually not needed for a remote redirect, and `-Indexes` has caused a 403 on a bare identifier URL. `-MultiViews` is the one with a real open question behind it — read [`htaccess/no-options-directive`](/rules/htaccess/no-options-directive) before removing one from a file you did not write. | | A `[R=406,L]` catch-all fallback | Copied from conneg examples, it swallows every request that does not match an earlier rule. [`htaccess/no-406-fallback`](/rules/htaccess/no-406-fallback) | | `RewriteCond %{HTTPS} !=on` and friends | The service is HTTPS-only already. | | A copied `Access-Control-Allow-Headers` line | The widely-copied version in this repository is truncated and wrong. [`htaccess/valid-cors-header`](/rules/htaccess/valid-cors-header) | | `RewriteBase` | Almost never needed when substituting an absolute URL. | ::: warning Other identifiers are not a style guide There are thousands of `.htaccess` files in this repository, written by more than a thousand people over more than a decade, and many are copies of copies. The fact that hundreds of files contain a directive is evidence that it was *copied*, not that it is *correct* — the truncated CORS header in [`htaccess/valid-cors-header`](/rules/htaccess/valid-cors-header) reached hundreds of files that way. Write the smallest rule set that does what you need, and understand every line of it. ::: ## Reference * [Apache mod_rewrite documentation](https://httpd.apache.org/docs/current/mod/mod_rewrite.html) * [RewriteRule flags](https://httpd.apache.org/docs/current/rewrite/flags.html) * [Introduction to mod_rewrite regular expressions](https://httpd.apache.org/docs/current/rewrite/intro.html) * [`.htaccess` on Wikipedia](https://en.wikipedia.org/wiki/.htaccess) * Worked examples in the repository: [`ids/examples/`](https://github.com/perma-id/w3id.org/tree/master/ids/examples) --- --- url: https://docs.w3id.org/guides/content-negotiation.md --- # Content negotiation If you are publishing a vocabulary or ontology, you probably want one IRI — `https://w3id.org/my-vocab/` — to give a human-readable HTML page to a browser and machine-readable RDF to a triple store. That is [content negotiation](https://en.wikipedia.org/wiki/Content_negotiation): the client says what it can handle in the `Accept` header, and the server responds accordingly. This is the most error-prone thing people do on this service. Rule *ordering* alone accounts for more corrective commits than any other single problem in the repository's history. Read this page carefully, and [test](./testing) what you write. ## How it works here w3id.org does not serve your content, so it cannot negotiate in the ordinary sense. What it does is inspect the `Accept` header and **redirect to a different URL** depending on what it finds. ``` Accept: text/html → 303 → https://example.org/vocab/index.html Accept: text/turtle → 303 → https://example.org/vocab/vocab.ttl Accept: application/ld+json→ 303 → https://example.org/vocab/vocab.jsonld ``` Use **303 See Other** for this. The identifier names a concept; the thing you are redirecting to is a *document about* that concept. That distinction is what 303 exists for, and it is long-standing practice for ontology IRIs. ## A worked example ```apache # # /my-vocab/ # # Content-negotiated IRIs for the My Vocabulary ontology. # # ## Contact # This space is administered by: # # Firstname Lastname # firstname@example.org # GitHub username: exampleuser RewriteEngine on # Turtle RewriteCond %{HTTP_ACCEPT} text/turtle RewriteRule ^$ https://example.org/vocab/vocab.ttl [R=303,L] # JSON-LD RewriteCond %{HTTP_ACCEPT} application/ld\+json RewriteRule ^$ https://example.org/vocab/vocab.jsonld [R=303,L] # RDF/XML RewriteCond %{HTTP_ACCEPT} application/rdf\+xml RewriteRule ^$ https://example.org/vocab/vocab.rdf [R=303,L] # N-Triples RewriteCond %{HTTP_ACCEPT} application/n-triples RewriteRule ^$ https://example.org/vocab/vocab.nt [R=303,L] # Anything else, including browsers and clients that send */*, gets the # human-readable documentation. This rule has no condition, so it always # matches -- which is exactly what a default is for. RewriteRule ^$ https://example.org/vocab/index.html [R=303,L] ``` Note the `\+` in `application/ld\+json` and `application/rdf\+xml`. The condition pattern is a regular expression, and `+` is a repetition operator. Unescaped, `application/ld+json` means "`application/l`, then one or more `d`, then `json`", which does not match the header you were aiming for. ## The two rules that matter ### 1. Order most specific first, default last `RewriteCond`/`RewriteRule` pairs are evaluated top to bottom, and the first match with `L` wins. So the ordering has to go from most specific to least. The failure mode is putting a rule that matches broadly near the top. A browser sends something like: ``` Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8 ``` A `RewriteCond %{HTTP_ACCEPT} text/html` placed above your RDF rules is fine. But a condition matching `\*/\*` placed above them will catch **every browser request too**, because `*/*` appears in that header. Put broad matches last. ### 2. The default must be a real response Always end with an unconditional rule that redirects somewhere useful. ::: danger Do not add a 406 catch-all This pattern gets copied from ontology templates and it is a trap: ```apache # Do not do this RewriteCond %{HTTP_ACCEPT} .+ RewriteRule ^$ - [R=406,L] ``` `.+` matches any non-empty `Accept` header — which is all of them. Placed anywhere it can be reached, it returns **406 Not Acceptable** to every real client, including browsers. See [`htaccess/no-406-fallback`](/rules/htaccess/no-406-fallback). If a client asks for something you do not have, giving it your HTML documentation is a far better answer than an error. ::: ## Declaring media types `AddType` tells Apache what content type to associate with a file extension. You only need it if w3id.org itself is serving a file with that extension — which, since you are redirecting to your own host, it usually is not. Where it does matter is on **your** server: make sure *it* serves `vocab.ttl` as `text/turtle` and not `text/plain` or `application/octet-stream`. Many static hosts get this wrong by default, and a correct redirect to a badly-typed file still fails for the client. If you do need them: ```apache AddType text/turtle .ttl AddType application/rdf+xml .rdf .owl AddType application/n-triples .nt AddType application/ld+json .jsonld ``` ## Version-aware IRIs A common layout is a "latest" IRI plus explicit version IRIs: ``` https://w3id.org/my-vocab/ → the current release https://w3id.org/my-vocab/1.2.0 → that specific release ``` ```apache RewriteEngine on # --- Latest release --------------------------------------------------- RewriteCond %{HTTP_ACCEPT} text/turtle RewriteRule ^$ https://example.org/vocab/latest/vocab.ttl [R=303,L] RewriteCond %{HTTP_ACCEPT} application/ld\+json RewriteRule ^$ https://example.org/vocab/latest/vocab.jsonld [R=303,L] RewriteRule ^$ https://example.org/vocab/latest/index.html [R=303,L] # --- A specific release ----------------------------------------------- # ^([0-9]+(\.[0-9]+)*)$ so that only things shaped like version numbers # match, rather than any sub-path at all. RewriteCond %{HTTP_ACCEPT} text/turtle RewriteRule ^([0-9]+(\.[0-9]+)*)$ https://example.org/vocab/$1/vocab.ttl [R=303,L] RewriteCond %{HTTP_ACCEPT} application/ld\+json RewriteRule ^([0-9]+(\.[0-9]+)*)$ https://example.org/vocab/$1/vocab.jsonld [R=303,L] RewriteRule ^([0-9]+(\.[0-9]+)*)$ https://example.org/vocab/$1/index.html [R=303,L] ``` Constrain the version pattern rather than using `^(.+)$`. A bare `.+` will also capture typos, trailing slashes, and paths you never intended to serve, and send them somewhere that 404s. ## Dispatching on a file extension instead Content negotiation is not the only option, and it is not always the best one. Letting people ask for a format explicitly in the URL is simpler, easier to debug, easier to paste into a bug report, and works with clients that send unhelpful `Accept` headers: ```apache RewriteEngine on RewriteRule ^vocab\.(ttl|jsonld|rdf|nt)$ https://example.org/vocab/vocab.$1 [R=303,L] ``` The two approaches combine well: negotiate on the bare IRI, and also honour an explicit extension when one is given. ## CORS If browser-based applications need to fetch your vocabulary with JavaScript, the **origin serving the content** needs to send CORS headers. That is your host, not w3id.org — a redirect response is not what the browser is reading the data from. You may see this in `.htaccess` files here: ```apache Header set Access-Control-Allow-Origin "*" ``` It is harmless on the redirect, but it is not what makes cross-origin fetching work. Configure CORS where the file is actually served. ::: danger Do not copy the Access-Control-Allow-Headers line A truncated, malformed `Access-Control-Allow-Headers` value was added to one file in this repository years ago and has since been copied into **hundreds of others**. It ends mid-word. If you copy an `.htaccess` from elsewhere in this repository and it contains a line ending in `If-Modified$`, delete it. See [`htaccess/valid-cors-header`](/rules/htaccess/valid-cors-header). ::: ## Browser detection Some older templates in this repository sniff the `User-Agent` header: ```apache RewriteCond %{HTTP_USER_AGENT} ^Mozilla/.* ``` This was a workaround for browsers with unhelpful `Accept` headers. It is fragile — essentially every HTTP client on earth claims to be Mozilla, including `curl` when told to, and many RDF tools. Prefer negotiating on `Accept` and ending with a sensible HTML default, which achieves the same thing without guessing. ## Test it Content negotiation is the easiest thing on this service to get subtly wrong, and the errors are invisible until somebody's triple store gets an HTML page. ```sh curl -sI -H 'Accept: text/turtle' https://w3id.org/my-vocab/ curl -sI -H 'Accept: application/ld+json' https://w3id.org/my-vocab/ curl -sI -H 'Accept: text/html' https://w3id.org/my-vocab/ curl -sI -H 'Accept: */*' https://w3id.org/my-vocab/ curl -sI https://w3id.org/my-vocab/ ``` Check every one of those, including the last two. See [Testing your changes](./testing) for how to run this against your rules before they are merged. --- --- url: https://docs.w3id.org/guides/maintain-an-id.md --- # Maintaining an identifier Adding an identifier is easy. Changing one that people already depend on is where the care is needed, because by then you no longer know who is relying on it. ## The mental model Assume that: * Somebody has your identifier embedded in a dataset you will never see. * Somebody has it hard-coded in software that is no longer maintained. * Somebody has it in a published paper, and cannot edit it. * Some of those clients cached your redirect, possibly permanently. You cannot notify any of them. So the guiding principle is: **changes should be additive, and old paths should keep resolving.** ## Who may change an identifier The maintainers listed in the identifier's `.htaccess` comments or `README.md`. If you are submitting a change to a directory you are not listed on, say so in the pull request and **tag one of the listed maintainers to approve it**. Reviewers check this. From the outside, an unexplained change to somebody else's namespace is indistinguishable from an attempt to take it over, and a reviewer has no way to tell the two apart without being told. If the listed maintainer is unreachable and you have a legitimate claim, say so — in the pull request, or by raising it first as an [issue](https://github.com/perma-id/w3id.org/issues) or on the [mailing list](https://lists.w3.org/Archives/Public/public-perma-id/) — rather than pushing the change through and leaving the reviewer to guess. ## Zero-downtime updates You control your target server; you do not control when the pull request changing this repository is merged. Everything between those two moments is a window in which the identifier is live and pointing somewhere, so sequence the two so that no arrangement of them breaks: 1. On the target, keep the old URLs working and **add** the new ones. Both resolve. 2. Open the pull request, and let it be reviewed and merged whenever it is. 3. Only then retire the old target resources, if retiring them is appropriate at all. Do it the other way round — remove the old URLs, then wait for review — and the identifier is broken for however long the review takes. If that is not possible — a target that cannot serve both sets of URLs at once, a coordinated announcement, an embargo — say so rather than hoping the timing works out. Open the pull request **as a draft** and describe the window you need. A draft cannot be merged, so it will not land early by accident, and a maintainer will work out a time with you; mark it ready when that time comes. ### Add before you remove The same rule applies inside the `.htaccess`. When your content moves, do not rewrite the existing rule in place and assume nothing was using the old path; you cannot see who is. Add the new rule, confirm it works, and only then consider removing anything. ```apache RewriteEngine on # New location, current as of 2026-09. RewriteRule ^vocab$ https://new-host.example.org/vocab.ttl [R=302,L] # Old paths, kept working. Clients out there still request these. RewriteRule ^ontology$ https://new-host.example.org/vocab.ttl [R=302,L] RewriteRule ^v1/vocab$ https://new-host.example.org/v1/vocab.ttl [R=302,L] ``` Old paths cost you two lines of configuration. Removing them costs somebody else a broken application. ## Keep the meaning stable An identifier names a thing. Redirecting it somewhere that describes a *different* thing is a breaking change even though every URL still resolves — and it is a silent one, because nothing 404s. Consumers just start getting wrong data. If the thing genuinely changed, mint a new identifier. ## Never repurpose an identifier Do not take over a directory that belonged to an abandoned project and point it at yours. The old identifier is still in other people's data, and now it resolves to something unrelated. This is worse than a broken link, because it fails silently. If a project is gone and you want a similar name, pick a different one. ## Avoid 301, and be careful undoing one **302** and **303** are re-checked by clients. **301 Moved Permanently** may be cached indefinitely — browsers in particular are aggressive about this — so a 301 you regret cannot be fully retracted. Some fraction of clients will keep following the old target for as long as their cache survives. Use 302 for ordinary redirects and 303 for content-negotiated ontology IRIs. Reserve 301 for cases where you are certain the move is permanent and you have thought about the consequences of being wrong. ## Test both before and after Before opening the pull request, check that the paths that worked before still work, and that the new ones do too: ```sh # against your local test server -- see the Testing guide curl -sI http://localhost:8080/my-project/vocab curl -sI http://localhost:8080/my-project/ontology # the old path curl -sI -H 'Accept: text/turtle' http://localhost:8080/my-project/ ``` Write the list of URLs you are checking into the pull request description. It tells the reviewer what you thought about, and it tells the next maintainer what mattered. ## Transferring maintainership Update the contact details in `.htaccess` and/or `README.md` in a pull request, and have **both** the outgoing and incoming maintainer visible on it — the outgoing maintainer opening it, or approving it, or commenting on it. Include the new maintainer's GitHub username. See [`meta/maintainer-github-username`](/rules/meta/maintainer-github-username). Do this while the outgoing maintainer is still reachable. The alternative is a namespace nobody can legitimately change. ## Retiring an identifier Identifiers are not deleted. Removing the directory turns the identifier into a 404 for everyone still using it, and destroys the record of what it once meant. Instead, make the outcome explicit. If the content is genuinely gone and is not coming back: ```apache RewriteEngine on # The Foo vocabulary was withdrawn in 2026. Nothing replaces it. RewriteRule ^ - [G] ``` `[G]` returns **410 Gone**, which tells a client the resource is deliberately withdrawn rather than temporarily missing. Better, where it applies: point at a tombstone page explaining what happened and what people should use instead. ```apache RewriteRule ^ https://example.org/foo-vocab-withdrawn.html [R=302,L] ``` Either way, keep the `README.md` and explain the situation in it. ## Housekeeping Some things worth doing when you touch an identifier you have not looked at in a while: * **Check your redirect target still resolves.** Link rot is why this service exists, and identifier directories are not exempt. Repository renames, ownership transfers, and branch renames on GitHub all silently break targets. * **Check for `refs/heads/` and `/blob/` in GitHub URLs** — both are common and both are wrong. See [`htaccess/github-raw-target`](/rules/htaccess/github-raw-target). * **Check the target is HTTPS.** See [`htaccess/https-target`](/rules/htaccess/https-target). * **Check the maintainer contact is still accurate**, and that the GitHub username is present. * **Check you are not pointing at a moving branch.** A target on `master` or `main` changes under you; a tag does not. ## Pull request expectations The same as for a new identifier — see [Creating an identifier](./create-an-id#_7-open-the-pull-request) — plus: * **Say what changed and why** in the description, not just what the diff shows. * **Say which URLs you tested.** * **Say whether any existing path stops working.** If one does, explain why that is acceptable. If you are not sure, keep it working. --- --- url: https://docs.w3id.org/guides/testing.md --- # Testing your changes ::: warning Automated checks help, but they cannot resolve your redirect Every pull request is checked automatically against the [rule catalogue](/rules/), and findings appear as comments on the changed lines. Run the same checks yourself first — see [Run the checks](#run-the-checks) below. What they **cannot** tell you is whether your redirect actually goes where you meant it to. No checker knows what your identifier is supposed to resolve to, or whether the URL on the other end is alive. That part is this page. A syntax error is also not a small failure: Apache returns **500 Internal Server Error for every URL under that directory**, not just for the rule you got wrong. ::: ## Run the checks The checker lives in `tools/check/` and reports only what your own change is responsible for, including work you have not committed yet: ```sh (cd tools/check && npm ci) # once node tools/check/bin/w3id-check.js ``` To look at one directory: ```sh node tools/check/bin/w3id-check.js ids/my-project ``` Each finding names a rule id, and every rule id has a page under [`/rules/`](/rules/) explaining what to do about it. See [`tools/check/README.md`](https://github.com/perma-id/w3id.org/blob/master/tools/check/README.md) for the full set of options. Not every rule is mechanized. A rule page marked `proposed` is documented but not yet checked, so a clean run does not mean every rule on this site is satisfied. ## The quickest check: read it again Before running anything, re-read your `.htaccess` looking for these specifically. They account for most of the breakage: * A **space inside the flag brackets** — `[R=302, L]`. This is a 500. [`htaccess/no-flag-whitespace`](/rules/htaccess/no-flag-whitespace) * A **leading slash or the directory name** in the pattern — `^/my-project/foo$`. This silently never matches. [`htaccess/pattern-relative-to-dir`](/rules/htaccess/pattern-relative-to-dir) * An **unescaped dot** — `^vocab.ttl$`. [`htaccess/escape-literal-dots`](/rules/htaccess/escape-literal-dots) * A **missing `$`** at the end of a pattern that should be exact. [`htaccess/anchor-patterns`](/rules/htaccess/anchor-patterns) * A **doubled slash** in the substitution. [`htaccess/no-double-slash`](/rules/htaccess/no-double-slash) * **`RewriteEngine on` missing** entirely. ## Check the redirect target exists Independently of any rewrite rules, confirm the URL you are redirecting *to* actually works: ```sh curl -sIL https://example.org/vocab/vocab.ttl | head -n 20 ``` Look for a final `200`, and check the `content-type` is what you expect. A redirect to a `404`, or to an ontology served as `text/plain`, is a broken identifier even though the rewrite rule is perfect. If your target is on GitHub, check it is a `raw.githubusercontent.com` URL rather than a `github.com/.../blob/...` page, and that it has no `refs/heads/` segment. See [`htaccess/github-raw-target`](/rules/htaccess/github-raw-target). ## Run the rules locally To actually exercise your `.htaccess` you need to serve the repository's `ids/` directory with a real Apache — that is the document root on the live service, so `ids/my-project/` is reachable at `/my-project/`. There is a supported setup in `tools/server/`, configured to match the live server. From the repository root, with Docker: ```sh cd tools/server && docker compose up ``` or without Docker and without root, which builds an Apache of its own the first time and is then as quick to start: ```sh tools/server/bin/build-httpd # once tools/server/bin/run-server ``` It is an ordinary Apache on an ordinary port, so anything that speaks HTTP will do: `curl`, a browser, an editor's REST client, whatever you already use. ```sh curl -sI http://localhost:8080/my-project/ ``` `resolve-identifier` is a shortcut for the common cases. It makes the requests described below and prints the status and `location:` for each one: ```sh tools/server/bin/resolve-identifier my-project ``` It does not know anything about your identifier, though. Whatever your rules do that is not on that list — the paths you dispatch on, the formats you negotiate, the ones you deliberately do not handle — you still have to ask for yourself. [Running a local server](./local-server) has the rest: the same thing without a build step, a native Apache setup, an unprivileged one for machines where you cannot install anything, HTTPS and self-signed certificates, how to read the results, and what to do when it will not start. ## What to check Run every one of these against your identifier. Use `-I` for headers only and `-s` to suppress the progress meter. ### The identifier root ```sh curl -sI http://localhost:8080/my-project/ ``` Expect a `302` (or `303`) and a `location:` header pointing where you intended. ### The bare form, with no trailing slash ```sh curl -sI http://localhost:8080/my-project ``` Because your identifier is a real directory, Apache first redirects this to `/my-project/` with a `301`, and your rule fires on the second request. That extra hop is normal and expected — do not try to eliminate it by adding `DirectorySlash Off` or `Options -Indexes`, which is how you get a `403`. See [`htaccess/no-options-directive`](/rules/htaccess/no-options-directive). ### Sub-paths ```sh curl -sI http://localhost:8080/my-project/vocab curl -sI http://localhost:8080/my-project/vocab/Thing curl -sI http://localhost:8080/my-project/1.2.0 ``` Include the paths you did *not* intend to match, and confirm they do something sensible rather than landing on a rule by accident. ### Content negotiation If you negotiate on `Accept`, start with one format and look at the result: ```sh curl -sI -H 'Accept: text/turtle' http://localhost:8080/my-project/ ``` Then check every format you handle **and** the cases you did not write a rule for: ```sh for a in 'text/turtle' 'application/ld+json' 'application/rdf+xml' \ 'application/n-triples' 'text/html' '*/*'; do printf '%-24s ' "$a" curl -sI -H "Accept: $a" http://localhost:8080/my-project/ \ | awk 'tolower($1)=="location:" || $1 ~ /^HTTP/ {printf "%s ", $2}' echo done ``` Also check with **no** `Accept` header at all, and with a browser's real one: ```sh curl -sI http://localhost:8080/my-project/ curl -sI -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' \ http://localhost:8080/my-project/ ``` Every one of these must produce a useful response. If any returns `406`, you have the [406 catch-all problem](/rules/htaccess/no-406-fallback). ### Follow the whole chain ```sh curl -sIL http://localhost:8080/my-project/vocab | grep -iE '^(HTTP|location)' ``` `-L` follows redirects, so you see the full chain and the final status. The last line should be a `200`. ## After it is merged Once a maintainer merges your pull request, the change is deployed to the service and is normally live shortly afterwards. Check it for real: ```sh curl -sIL https://w3id.org/my-project/ | grep -iE '^(HTTP|location)' ``` If something is wrong, open an issue describing what you expected and what you got. Do not open another pull request changing the same rules until you know what went wrong — a second guess on top of a first guess is harder for everyone to unpick. --- --- url: https://docs.w3id.org/guides/local-server.md --- # Running a local server The only way to know what your `.htaccess` actually does is to serve it with a real Apache. This page sets one up on your machine, configured the way the live service is configured, so `ids/my-project/` answers at `http://localhost:8080/my-project/`. ::: info This page has had limited testing All four setups have been run end to end against this repository's own `ids/` directory, and they agree with each other: the identifier root, the bare no-slash form, sub-paths, `Accept` negotiation, the site root, a missing identifier and a request for an `.htaccess` file all behave as described below. The unprivileged setup is the newest and least exercised of the four, and one option within it has not been run at all: pointing it at an already-installed Apache with `W3ID_ALLOW_SYSTEM_HTTPD`. What has been tested there is the Apache it builds for itself. That is not much exposure, and the setups touch Docker versions, Apache builds and host networking that vary. If something here does not work for you, or works only after you change it, please [open an issue](https://github.com/perma-id/w3id.org/issues) rather than working around it quietly — that feedback is what this page needs most. ::: You do not need this page for a simple redirect. Read your rules carefully, [run the checks](./testing#run-the-checks), and confirm the target resolves. Come here when a rule is not matching and you cannot see why, when you are doing content negotiation, or when you want to be sure before asking a volunteer to review it. ## What it emulates The local server mirrors the live single-server configuration: the same Apache modules, the same `AllowOverride`, the same document root layout, the same `Options`. Two things are deliberately different. * **No redirect to HTTPS.** The live server bounces all plain HTTP to `https://w3id.org/`. Locally that would send every test request to the real service, so you would be testing the deployed rules instead of your own. * **No TLS.** Plain HTTP is enough, because rewrite rules do not behave differently by scheme. See [HTTPS](#https-and-certificates) if you need it anyway. The module list is restricted on purpose, to exactly what the live server runs. If you use a directive from some other Apache module, you get a 500 here rather than a working local test and a broken deployment. Adding a `LoadModule` line to make your own rules work is the wrong fix — production will not have it. ## With Docker The recommended path. From the repository root: ```sh cd tools/server docker compose up ``` Leave it running. In another terminal: ```sh curl -sI http://localhost:8080/my-project/ ``` To stop it, press Ctrl-C, or run `docker compose down` from `tools/server/`. Apache's logs go to the container's output, so the terminal running `docker compose up` shows every request and every error. That log is the only thing that names the file and line when an `.htaccess` fails to parse. ```sh docker compose logs -f w3id # if you started it with -d ``` The identifier tree is mounted read-only. The server cannot modify the files you are about to commit. ### Without building an image If you would rather not wait for a build, the stock Apache image can be pointed at the tree directly. From the repository root: ```sh docker run --rm -p 8080:80 \ -v "$PWD/ids:/usr/local/apache2/htdocs:ro" \ -v "$PWD/tools/server/conf:/w3id/conf:ro" \ httpd:2.4 \ httpd -D FOREGROUND -c "Include /w3id/conf/docker-direct.conf" ``` This is the less faithful path. Because the image's own configuration runs first, it does **not** give you the restricted module set, so an `.htaccess` that works this way can still fail in production. Use it to get moving; use `docker compose up` when the answer matters. ## With a native Apache Closest to how the service actually runs. On Debian or Ubuntu: ```sh sudo apt install apache2 sudo a2enmod rewrite headers setenvif env mime dir alias autoindex negotiation \ deflate filter reqtimeout access_compat ``` Copy the site configuration into place and edit one line in it — the `Define W3ID_REPO` at the top must be the absolute path of your checkout: ```sh sudo cp tools/server/conf/w3id-site.conf /etc/apache2/sites-available/ sudo editor /etc/apache2/sites-available/w3id-site.conf sudo a2ensite w3id-site sudo apache2ctl configtest sudo systemctl reload apache2 ``` `configtest` before reloading, every time. A syntax error in the site file stops Apache from starting at all, rather than failing one directory. Apache must be able to read your checkout. If it is under `/home`, either move it somewhere like `/srv/w3id.org` or grant the `www-data` user traversal — a 403 on every URL usually means it cannot get through a parent directory. Logs land in `/var/log/apache2/w3id-local-error.log`. ## Without Docker or root For a machine where you cannot install anything — a container, a locked-down workstation, a shared host — or where you would simply rather not. This builds an Apache into your own cache directory and runs it as you. Nothing is installed system-wide and no step uses `sudo`. Build it once. This downloads Apache, APR and PCRE2, checks them against checksums pinned in the script, and compiles them. Expect a few minutes, most of it Apache itself: ```sh tools/server/bin/build-httpd ``` Then start it. It serves the `ids/` directory of the checkout the script itself lives in, so if you have several worktrees each one serves its own tree: ```sh tools/server/bin/run-server ``` That stays in the foreground until you press Ctrl-C. To leave one running instead: ```sh tools/server/bin/run-server start tools/server/bin/run-server status tools/server/bin/run-server stop ``` `status` prints where the error and access logs are. `run-server test` checks the configuration without starting anything, and prints the full `httpd` command it would run, so nothing about the setup is hidden from you. The port is 8080, the same one every example on this page uses. `W3ID_PORT` changes it, and `W3ID_BASE` is how you tell `resolve-identifier`: ```sh W3ID_PORT=8081 tools/server/bin/run-server start W3ID_BASE=http://localhost:8081 tools/server/bin/resolve-identifier my-project ``` Two limits are worth knowing before you choose this setup. The build has no `mod_ssl`, so [HTTPS](#https-and-certificates) is not available here — use Docker or a native Apache if you need it. And if you already have Apache installed and would rather not build one, `W3ID_ALLOW_SYSTEM_HTTPD=1` makes `run-server` use it, at a cost: a distribution Apache links some modules into the binary, where no configuration can unload them, so a rule relying on a module the live server does not have can pass locally and still fail in production. That is the one thing this setup is otherwise good at catching. ## Checking a redirect There is a script for the requests worth making: ```sh tools/server/bin/resolve-identifier my-project ``` It reports the status and `Location` for the identifier root, the bare form with no trailing slash, and each of the `Accept` headers that matter, then follows the whole chain to see where it ends up. Give it sub-paths as extra arguments — including the ones you did **not** mean to match, since a rule catching a request by accident is exactly what this finds: ```sh tools/server/bin/resolve-identifier my-project vocab vocab/Thing 1.2.0 ``` For a first run, `examples` is a real identifier already in the tree, so it works before you have written anything of your own: ```sh tools/server/bin/resolve-identifier examples ``` Set `W3ID_BASE` if you changed the port: ```sh W3ID_BASE=http://localhost:9000 tools/server/bin/resolve-identifier my-project ``` Everything the script does, you can do by hand — see [What to check](./testing#what-to-check) in the testing guide for the individual `curl` invocations. ## Reading the result **A `302` or `303` with a `location:`** pointing where you intended is success. Check the URL character by character; a redirect to the wrong place is still a redirect. **A `301` on the bare form** is normal. Because your identifier is a real directory, Apache redirects `/my-project` to `/my-project/` before any of your rules run, and your rule fires on the second request. Do not try to remove that hop — the directives that appear to fix it cause a 403 instead. See [`htaccess/no-options-directive`](/rules/htaccess/no-options-directive). **A `500`** is a syntax error, and it affects every URL under that directory, not just the rule you got wrong. The server log names the file and the line. The usual cause is a space inside the flag brackets — [`htaccess/no-flag-whitespace`](/rules/htaccess/no-flag-whitespace). **A `404`, or your rule not firing at all**, usually means the pattern never matched. The pattern is relative to the directory, so a leading slash or a repeated directory name silently matches nothing — [`htaccess/pattern-relative-to-dir`](/rules/htaccess/pattern-relative-to-dir). An unescaped dot is the other common cause — [`htaccess/escape-literal-dots`](/rules/htaccess/escape-literal-dots). **A `406`** means a content-negotiation catch-all is matching real requests — [`htaccess/no-406-fallback`](/rules/htaccess/no-406-fallback). **A `403` on every URL** is a filesystem permission problem on the native setup, not a rule problem. When a rule will not match and you have run out of ideas, turn on mod_rewrite's tracing and watch it decide: ```apache LogLevel warn rewrite:trace3 ``` Add that to the virtual host — not to your `.htaccess`, where it does not belong and would not be accepted. It logs every rewrite Apache attempts, which is verbose and worth it. ## HTTPS and certificates **You almost certainly do not need this.** Rewrite rules do not behave differently over HTTPS, so `http://localhost:8080` tests the same rules. The one thing plain HTTP cannot exercise is a rule that inspects `%{HTTPS}` or `%{SERVER_PORT}`, which is rare and discouraged. If you do need it, generate a certificate for `localhost`. Run this from the `tools/server` directory, which has a `tls/` entry in `.gitignore` — so a private key generated there cannot be committed by accident: ```sh cd tools/server mkdir -p tls openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ -keyout tls/localhost.key \ -out tls/localhost.crt \ -subj '/CN=localhost' \ -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1' ``` Then enable `mod_ssl` and point a `` at those two files with `SSLEngine on`, including the same `conf/w3id.conf` as the HTTP host. Be aware of what a self-signed certificate does and does not get you: * **`curl` needs `-k`**, otherwise it refuses to connect. That also disables the check you might have been hoping to make. * **Browsers show a full-page interstitial**, and there is no way to make a self-signed certificate simply look valid. Getting a green padlock means creating a local certificate authority, adding it to your operating system's trust store, and issuing a certificate from it — [`mkcert`] automates this. Trusting your own CA has real consequences for your machine; understand them before you do it. * **Do not test HTTPS in a browser that has visited the real `w3id.org`.** The live service sends HSTS, so the browser may refuse to let you click through a certificate warning for that hostname at all. Use `localhost`, which is what the certificate above is for. [`mkcert`]: https://github.com/FiloSottile/mkcert ## When it will not start **`Address already in use`** — something already holds port 8080. Change the host port in `tools/server/compose.yaml`, or, on the native setup, check whether `/etc/apache2/ports.conf` also listens on 8080; the site file adds its own `Listen`. **Every URL returns 404, including `/`** — the document root is not pointing at `ids/`. Under Docker, check you ran the command from the repository root so `$PWD/ids` resolved. Under Apache, check `Define W3ID_REPO`. **Your rules are completely ignored, but files are served** — `mod_rewrite` is not loaded, or `AllowOverride` is not `All`. This is the failure that looks like success: Apache serves the tree and silently discards every rule in it. **Changes to an `.htaccess` seem not to take effect** — they should apply on the next request, with no reload. If they do not, you are probably editing a different file than the one being served; check the path the container has mounted. ## What this cannot tell you A local server proves what Apache does with your rules. It says nothing about whether the answer is right. It does not know where your identifier is supposed to point, and it does not check that the target is alive — the redirect target is on the public internet and is somebody else's server. Confirm that separately: ```sh curl -sIL https://example.org/vocab.ttl | grep -iE '^(HTTP|content-type)' ``` And when the pull request is merged, check it for real, because the deployed configuration is the only one that counts: ```sh curl -sIL https://w3id.org/my-project/ | grep -iE '^(HTTP|location)' ``` --- --- url: https://docs.w3id.org/rules.md --- # Rule catalogue Each rule on this page describes one specific, checkable thing that contributions to this repository get wrong. They exist because these mistakes recur — most of them have needed repeated corrective commits over the years. Every rule has a stable URL of the form `https://w3id.org/docs/rules/`, so a reviewer or an automated check can link to the exact explanation rather than restating it. If you are looking for a walkthrough rather than a reference, start with the [guides](/guides/). ## How to read a rule | Field | Meaning | | --- | --- | | **`id`** | Stable identifier. Also the page's URL and how checks refer to it. | | **`severity`** | `error` — the identifier is broken or the change is not acceptable. `warning` — it works, but it will cause problems. `notice` — advisory. | | **`status`** | `enforced` — an automated check emits this id. `proposed` — documented, not yet checked. | | **`applies-to`** | Which files the rule concerns, as a reader-facing summary. The checker's own patterns are the source of truth. | The three severities are the three annotation levels GitHub Actions understands, so a finding keeps the same word from the checker's output through to the pull request. ## Running the checks The checker lives in `tools/check/` and needs no arguments: ```sh (cd tools/check && npm ci) # once node tools/check/bin/w3id-check.js ``` It compares your branch against `upstream/master` if you have an `upstream` remote, and `origin/master` otherwise. It counts work you have not committed yet, and reports only what your own change is answerable for — the repository's existing backlog stays out of your way. To look at one directory: ```sh node tools/check/bin/w3id-check.js ids/my-project ``` Only `error` findings block a pull request. A `warning` is something a maintainer will probably ask about; a `notice` is there so you have seen it. ::: warning The existing repository is not a style guide There are thousands of `.htaccess` files here, written by more than a thousand people over more than a decade, and many are copies of copies. That a pattern appears in hundreds of files is evidence it was *copied*, not that it is *correct* — [`htaccess/valid-cors-header`](./htaccess/valid-cors-header) documents a broken line that spread to hundreds of files exactly that way. These rules are maintainer guidance. Existing files are not. ::: The rules are not infallible either. If one is wrong about your files, or if something that should have been caught was not, please [open an issue](https://github.com/perma-id/w3id.org/issues). A false positive is a bug in the rule rather than something to work around, and a check that should exist is hardest to notice from the inside — the person best placed to report it is whoever has just watched their own mistake pass. ## [Files and directories](./files/) | Rule | Severity | What | | --- | --- | --- | | [`files/only-allowed-names`](./files/only-allowed-names) | error | Only `.htaccess` and a README — no content files, and the rules file must be named exactly | | [`files/no-empty-htaccess`](./files/no-empty-htaccess) | error | An `.htaccess` with no directives resolves to 404 | | [`files/no-executable-bit`](./files/no-executable-bit) | error | A file that is not a program must not be executable | | [`files/htaccess-required`](./files/htaccess-required) | warning | Something in the directory must answer requests | | [`files/prefer-readme-md`](./files/prefer-readme-md) | warning | Write it as Markdown, called `README.md` | ## [Repository shape](./tree/) | Rule | Severity | What | | --- | --- | --- | | [`tree/identifier-under-ids`](./tree/identifier-under-ids) | error | Identifier directories must be under `ids/` | | [`tree/no-case-collision`](./tree/no-case-collision) | error | Two names differing only by case cannot both be checked out | | [`tree/only-own-identifier`](./tree/only-own-identifier) | warning | Keep a change to one identifier | ## [`.htaccess` syntax](./htaccess/) | Rule | Severity | What | | --- | --- | --- | | [`htaccess/no-flag-whitespace`](./htaccess/no-flag-whitespace) | error | No spaces inside the `[...]` flag list | | [`htaccess/space-before-flags`](./htaccess/space-before-flags) | error | A space *before* the `[...]`, or it is part of the target | | [`htaccess/valid-rewrite-flags`](./htaccess/valid-rewrite-flags) | error | Flag names must be ones mod_rewrite knows | | [`htaccess/no-inline-comment`](./htaccess/no-inline-comment) | error | `#` only starts a comment at the start of a line | | [`htaccess/uppercase-rewrite-flags`](./htaccess/uppercase-rewrite-flags) | warning | Write flags in upper case | ## [Rules that never match](./htaccess/) | Rule | Severity | What | | --- | --- | --- | | [`htaccess/rewrite-engine-required`](./htaccess/rewrite-engine-required) | error | `RewriteRule` does nothing without `RewriteEngine on` | | [`htaccess/pattern-relative-to-dir`](./htaccess/pattern-relative-to-dir) | error | No leading slash, no repeated directory name | | [`htaccess/anchor-patterns`](./htaccess/anchor-patterns) | warning | Make an optional trailing group optional | | [`htaccess/no-greedy-capture`](./htaccess/no-greedy-capture) | warning | `^(.+)/?$` swallows the slash | | [`htaccess/escape-literal-dots`](./htaccess/escape-literal-dots) | warning | `.` is a wildcard; escape a literal dot | ## [Redirect targets](./htaccess/) | Rule | Severity | What | | --- | --- | --- | | [`htaccess/no-open-redirect`](./htaccess/no-open-redirect) | error | Never build the target hostname from the request | | [`htaccess/https-target`](./htaccess/https-target) | warning | Redirect to `https://` | | [`htaccess/github-raw-target`](./htaccess/github-raw-target) | warning | Raw file URLs, not `/blob/` pages | | [`htaccess/no-rawgit`](./htaccess/no-rawgit) | error | RawGit has shut down; use jsDelivr | | [`htaccess/no-double-slash`](./htaccess/no-double-slash) | warning | No `//` in the produced URL | | [`htaccess/no-self-redirect`](./htaccess/no-self-redirect) | warning | Point at the destination, not back at w3id.org | | [`htaccess/avoid-permanent-redirect`](./htaccess/avoid-permanent-redirect) | warning | A 301 cannot be taken back | ## [Directives and negotiation](./htaccess/) | Rule | Severity | What | | --- | --- | --- | | [`htaccess/allowed-directives`](./htaccess/allowed-directives) | error | No code execution, proxying, or access control | | [`htaccess/valid-cors-header`](./htaccess/valid-cors-header) | warning | Do not copy the truncated CORS line | | [`htaccess/no-406-fallback`](./htaccess/no-406-fallback) | warning | Ending negotiation in 406 answers nobody | | [`htaccess/no-options-directive`](./htaccess/no-options-directive) | *proposed* | `Options` directives — open questions | ## [File format](./format/) | Rule | Severity | What | | --- | --- | --- | | [`format/no-bom`](./format/no-bom) | error | No byte order mark — it makes Apache return 500 | | [`format/no-crlf`](./format/no-crlf) | warning | Unix line endings, not CRLF | | [`format/final-newline`](./format/final-newline) | warning | End the file with a newline | | [`format/no-trailing-whitespace`](./format/no-trailing-whitespace) | warning | No trailing whitespace that does nothing | | [`format/no-excessive-blank-lines`](./format/no-excessive-blank-lines) | warning | No long runs of blank lines, and none at the start or end | ## [Markdown](./markdown/) | Rule | Severity | What | | --- | --- | --- | | [`markdown/prefer-list-over-line-breaks`](./markdown/prefer-list-over-line-breaks) | notice | A stack of manual line breaks reads better as a list | ## [Pull request shape](./git/) These look at the commits rather than the files, so they are reported on a pull request and not by a run scoped to a directory. | Rule | Severity | What | | --- | --- | --- | | [`git/minimal-commits`](./git/minimal-commits) | warning | One identifier change should be one commit | | [`git/no-merge-commits`](./git/no-merge-commits) | warning | Rebase; do not merge master into your branch | | [`git/descriptive-commit-message`](./git/descriptive-commit-message) | warning | Name the identifier, not the filename | | [`git/no-session-link`](./git/no-session-link) | warning | No links to a private agent session | | [`git/branch-not-stale`](./git/branch-not-stale) | notice | Start from a recent master | ## [Metadata](./meta/) | Rule | Severity | What | | --- | --- | --- | | [`meta/document-identifier-root`](./meta/document-identifier-root) | warning | Claim the root of an identifier with a maintainer record | | [`meta/maintainer-github-username`](./meta/maintainer-github-username) | warning | Record who maintains the identifier, with a GitHub username | | [`meta/rule-docs-exist`](./meta/rule-docs-exist) | warning | Every rule has a page and every page has a rule | ## For tooling Every rule page carries YAML frontmatter with `id`, `title`, `severity`, `status`, and `applies-to`. The `id` is always the page's path relative to `docs/rules/` with the `.md` removed — so `docs/rules/htaccess/https-target.md` has the id `htaccess/https-target` — and a checker can enumerate the rule set from `docs/rules/**/*.md` and emit `https://w3id.org/docs/rules/` in its output. Pages named `index.md` are catalogue pages, not rules, and carry no `id`. That contract is itself checked, by [`meta/rule-docs-exist`](./meta/rule-docs-exist): every rule the checker implements must have a page, every page claiming `status: enforced` must have a rule behind it, and a page's `id` must match its path. `applies-to` is outside that contract. It is a summary written for readers, and nothing verifies it against the patterns the rule actually selects on, so do not use it to decide which files to check — read the rule. The frontmatter fields safe to build on are the ones above: `id`, `severity` and `status`. Raw Markdown for any page is available by appending `.md` to its URL, and the whole site is published as [`llms.txt`](https://w3id.org/docs/llms.txt) and [`llms-full.txt`](https://w3id.org/docs/llms-full.txt). --- --- url: https://docs.w3id.org/rules/files.md --- # File and directory rules What an identifier directory may contain, what its files are called, and how they are recorded. | Rule | Severity | What | | --- | --- | --- | | [`files/only-allowed-names`](./only-allowed-names) | error | Only `.htaccess` and a README — no content files, and the rules file must be named exactly | | [`files/no-empty-htaccess`](./no-empty-htaccess) | error | An `.htaccess` with no directives resolves to 404 | | [`files/no-executable-bit`](./no-executable-bit) | error | A file that is not a program must not be executable | | [`files/htaccess-required`](./htaccess-required) | warning | Something in the directory must answer requests | | [`files/prefer-readme-md`](./prefer-readme-md) | warning | Write it as Markdown, called `README.md` | Back to the [rule catalogue](../). --- --- url: https://docs.w3id.org/rules/files/only-allowed-names.md --- # `files/only-allowed-names` **Severity:** error · **Status:** enforced · **Applies to:** `ids/**` ## What An identifier directory contains its redirect rules and, optionally, a README describing the identifier and its maintainers, and no other files: ``` ids/my-project/ ├── .htaccess └── README.md ``` The `README.md` is optional. The rules file must be named **exactly** `.htaccess` — lowercase, leading dot, no extension. This rule accepts any README name GitHub will render — `readme.md`, `README.adoc`, `README.rst` and the rest — because those are working files, not mistakes. Preferring Markdown called `README.md` is a separate and much softer matter: see [`files/prefer-readme-md`](./prefer-readme-md). ## Why **w3id.org is a redirection service, not a file host.** It answers requests with an HTTP redirect; it does not serve documents. A `.ttl`, `.owl`, `.jsonld` or `index.html` committed here is never served to anyone — the redirect fires first — so it does nothing except sit in the repository. It is not a size question. A one-line JSON-LD context is as out of scope as a fifty-megabyte ontology. The repository is cloned in full onto the production host and is shared infrastructure for over two thousand identifiers, so it is deliberately kept to configuration only. This is the most common out-of-scope contribution, it arrives at every size, and it is reverted every time. **A misnamed rules file does nothing at all.** Apache reads per-directory configuration from a file with that exact name. `htaccess.txt` is just a file sitting in the repository, the identifier resolves to a 404, and the contributor cannot see why because the file is right there in the pull request. Commits exist in this repository purely to rename it after the fact. The usual cause is GitHub's web editor, which will not accept a filename beginning with a dot typed into an empty filename box, so people save `htaccess.txt` meaning to fix it later. Variants that have all happened here: `htaccess.txt`, `htaccess.txt.txt`, `.htaccess.txt`, `htaccess`, `default.htaccess`, and `.htaccess`. ## Wrong ``` ids/my-project/ ├── htaccess.txt ← not read by Apache; the identifier 404s ├── my-vocab.ttl ← never served ├── context.jsonld ← never served ├── index.html ← never served ├── documentation/ ← never served └── CONTRIBUTING.md ← not this repository's business ``` Also seen and removed: `.gitignore` files, `DECISIONS.md`, test fixtures, symlinks, `desktop.ini`, and editor history directories. ## Right ``` ids/my-project/ ├── .htaccess └── README.md ``` ## How to fix **For content files**, publish them somewhere you control and point the identifier at them — GitHub Pages, your own site, a persistent archive such as Zenodo, or a vocabulary server: ```apache RewriteEngine on RewriteRule ^vocab\.ttl$ https://example.org/vocab/vocab.ttl [R=303,L] ``` If you already committed them, remove them in the same pull request: ```sh git rm ids/my-project/my-vocab.ttl ids/my-project/context.jsonld ``` **For a misnamed rules file in the GitHub web interface**, use **Add file → Create new file** and type the *whole path* into the filename box, directories included: ``` ids/my-project/.htaccess ``` Typing the path with directories in front is what makes GitHub accept the leading dot. If you already committed the wrong name: ```sh git mv ids/my-project/htaccess.txt ids/my-project/.htaccess ``` Locally, note that `.htaccess` is hidden from a plain `ls` — use `ls -a`, and check that `git status` actually shows it. ## How to check Run `w3id-check` with `--rule files/only-allowed-names` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule files/only-allowed-names ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`files/prefer-readme-md`](./prefer-readme-md) * [`htaccess/github-raw-target`](../htaccess/github-raw-target) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/files/no-empty-htaccess.md --- # `files/no-empty-htaccess` **Severity:** error · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What An `.htaccess` that is empty, or contains only comments, does not configure anything. ## Why An identifier whose rules file says nothing resolves to a 404. To anyone using the identifier that is indistinguishable from an identifier that was never created — except that the directory is there, so the name is taken. This usually happens one of two ways: a file created as a placeholder and never filled in, or a file whose rules were commented out during debugging and never restored. ## Wrong ```apache # tbd ``` ```apache # Redirects for my-project #RewriteEngine on #RewriteRule ^$ https://example.org/ [R=302,L] ``` ## Right ```apache # Redirects for my-project RewriteEngine on RewriteRule ^$ https://example.org/ [R=302,L] ``` ## The exception: a directory that only groups sub-identifiers A directory may exist only to hold sub-identifiers that each resolve on their own, and then it has no redirect of its own to carry: ``` ids/my-project/ ├── .htaccess ← comments only, and correct ├── v1/.htaccess ← resolves └── v2/.htaccess ← resolves ``` Comments there are the recommended way to claim the root of such an identifier — see [`meta/document-identifier-root`](../meta/document-identifier-root) — so this rule stays quiet when a descendant `.htaccess` carries directives. `files/htaccess-required` makes the same exception. The check is for a *descendant that resolves*, not for comments that look purposeful. A comments-only `.htaccess` with nothing below it is still an error, because nothing under that name resolves at all. An empty file is always an error: a zero-byte placeholder claims a name and serves nothing. ## How to fix Write the redirect, or remove the directory so the name is free for somebody who will use it. ## How to check Run `w3id-check` with `--rule files/no-empty-htaccess` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule files/no-empty-htaccess ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`files/htaccess-required`](./htaccess-required) * [`htaccess/rewrite-engine-required`](../htaccess/rewrite-engine-required) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/files/no-executable-bit.md --- # `files/no-executable-bit` **Severity:** error · **Status:** enforced · **Applies to:** `**/*` ## What A file committed with the executable bit set — mode `100755` rather than `100644` — when it is not a program. Inside an identifier directory that means any file at all: an `.htaccess` and a README are the only things that belong there, and neither is run by anything. Elsewhere in the repository it means a file that does not begin with a `#!` line, since that is what makes a file runnable. ## Why **The bit does not break your identifier.** w3id.org resolves identifiers and answers with a redirect; it never executes anything it stores. An `.htaccess` at mode `755` redirects exactly as one at `644` does. This rule is not reporting an outage. What it reports is **recorded state that is not true**. The mode is part of what git stores, it appears in the diff of every later change to the file, and it says the file is a program when it is a paragraph of contact details or six lines of rewrite rules. It is never deliberate. It arrives with a file copied off a Windows, FAT or exFAT volume, unpacked from a zip that preserved modes, or written under a permissive umask — and then it persists, because nothing about a working redirect ever draws attention to it. It also spreads: an identifier directory is the most copied thing in this repository, and a namespace cloned as a template brings the bit with it. That is how the current backlog arose. Of 5431 tracked files, 96 carry the bit; 91 of those are `.htaccess` and `README.md` files under `ids/`, and a single namespace accounts for 80 of them. The five that are genuinely executable are the checker's own entry points and the local server scripts, and every one of them begins with `#!`. No file in the repository that lacks the bit begins with one. So outside the identifier tree the shebang separates a program from a data file exactly, and that is the test this rule uses. ## Wrong ``` $ ls -l ids/my-project/ -rwxr-xr-x .htaccess ← mode 755; nothing runs it -rwxr-xr-x README.md ← mode 755; it is prose ``` ``` $ git ls-files -s ids/my-project/ 100755 6f1c… 0 ids/my-project/.htaccess 100755 a904… 0 ids/my-project/README.md ``` ## Right ``` $ git ls-files -s ids/my-project/ 100644 6f1c… 0 ids/my-project/.htaccess 100644 a904… 0 ids/my-project/README.md ``` ## How to fix ```sh chmod 644 ids/my-project/.htaccess ids/my-project/README.md git add ids/my-project/ ``` The mode change is a change like any other, so it needs to be staged and committed. `git status` shows the file as modified even though no character of it changed. On a filesystem that does not record the bit — Windows, FAT, exFAT, some network mounts — `chmod` does nothing git can see. Tell the index directly: ```sh git update-index --chmod=-x ids/my-project/.htaccess ``` To find every affected file in a directory: ```sh git ls-files -s ids/my-project/ | grep ^100755 ``` If a file outside `ids/` really is a script, give it a `#!` line instead of clearing the bit. ## How to check Run `w3id-check` with `--rule files/no-executable-bit` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule files/no-executable-bit ids/my-project ``` The check reads the mode git has recorded, and — for work you have not committed yet — the mode on disk. A bit set but not yet staged is still reported, so you do not have to commit to find out. [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`files/only-allowed-names`](./only-allowed-names) * [`format/no-bom`](../format/no-bom) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/files/htaccess-required.md --- # `files/htaccess-required` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**` ## What A directory under `ids/` must contain an `.htaccess`, or contain a sub-directory that does. ## Why The `.htaccess` is the only thing that makes an identifier resolve. Without one — anywhere in the directory or below it — `https://w3id.org/my-project/` returns 404, and the directory is doing nothing but occupying a name. A directory that exists only to group sub-identifiers is fine and is not reported: ``` ids/my-org/ ├── project-a/.htaccess └── project-b/.htaccess ``` `ids/my-org/` has no rules of its own, but everything under it resolves. ## Wrong ``` ids/my-project/ └── README.md ``` Nothing here answers a request. ## Right ``` ids/my-project/ ├── .htaccess └── README.md ``` ## How to fix Add the file. The smallest useful one: ```apache RewriteEngine on RewriteRule ^$ https://example.org/my-project/ [R=302,L] ``` If the directory was left behind by a change that removed its rules, remove the directory too. ## How to check Run `w3id-check` with `--rule files/htaccess-required` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule files/htaccess-required ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`files/no-empty-htaccess`](./no-empty-htaccess) * [`htaccess/rewrite-engine-required`](../htaccess/rewrite-engine-required) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/files/prefer-readme-md.md --- # `files/prefer-readme-md` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**` ## What If an identifier directory has a README, write it in Markdown and call it `README.md`. ## Why GitHub renders a README under a long list of names and markup formats, so `readme.md`, `README.MD`, `README.adoc` and `README.rst` all appear on the directory page just as `README.md` does. **Nothing is broken.** This is a convention, not a fix. The convention earns its keep in small ways. Every identifier's documentation reads and edits the same, contributors copying a neighbouring directory copy something consistent, and a reviewer skimming a pull request does not have to work out which syntax a diff is written in. There is one case where the name does change what a reader sees. A file written as Markdown but *not* named with a Markdown extension — a `README` or `README.txt` opening with a `#` heading — is shown verbatim, so its headings and links appear as literal punctuation rather than being rendered. ### What the message is telling you The rule says different things depending on how far the file is from the convention, because the work involved differs: | The file is | GitHub shows it as | What it takes | | --- | --- | --- | | `readme.md`, `README.MD`, `README.markdown` | Markdown | a rename | | `README.adoc`, `README.rst`, `README.textile`, … | that markup, rendered | a **conversion** | | `README`, `README.txt` containing Markdown | verbatim text | a rename | | `README`, `README.txt` containing prose | verbatim text | a conversion | A conversion is genuinely more than a `git mv`. AsciiDoc writes a heading `= Title`, reStructuredText underlines it, Markdown prefixes it with `#`; links, emphasis and line breaks differ too. Renaming the file without rewriting the content leaves a page that renders as a jumble. ### Names this rule does not accept The recognised extensions are the ones GitHub itself renders: `.md`, `.markdown`, `.mdown`, `.mkdn`, `.adoc`, `.asciidoc`, `.asc`, `.rst`, `.org`, `.textile`, `.rdoc`, `.creole`, `.mediawiki`, `.wiki` and `.pod`, plus a bare `README` and `README.txt`. A name outside that set — `README.me`, `README..md`, `_readme.md` — is not a README to GitHub at all. It will not appear on the directory page under any syntax, so it is reported by [`files/only-allowed-names`](./only-allowed-names) as a file that does not belong, not by this rule. ## Wrong ``` ids/my-project/readme.md ← renders fine, wrong name ids/my-project/README.MD ← renders fine, wrong name ids/my-project/README.adoc ← renders fine, wrong syntax ids/my-project/README ← if it contains Markdown, it is shown verbatim ``` ## Right ``` ids/my-project/README.md ``` ## How to fix If only the name is wrong: ```sh git mv ids/my-project/readme.md ids/my-project/README.md ``` On a case-insensitive filesystem — macOS, Windows — Git needs two steps to record a change of case: ```sh git mv ids/my-project/readme.md ids/my-project/readme-tmp.md git mv ids/my-project/readme-tmp.md ids/my-project/README.md ``` If the content is in another markup format, rewrite it as Markdown as well as renaming the file. These READMEs are short — a heading, a sentence about the namespace, and who maintains it — so this is usually a few minutes' work by hand. [`pandoc`](https://pandoc.org/) will do the mechanical part if you would rather not: ```sh pandoc -f asciidoc -t gfm ids/my-project/README.adoc \ -o ids/my-project/README.md git rm ids/my-project/README.adoc ``` Read the result before committing it. Converters are good at headings and lists and less good at anything unusual, and a README that came out of one still has to say the same things it said before. ## How to check Run `w3id-check` with `--rule files/prefer-readme-md` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule files/prefer-readme-md ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`meta/document-identifier-root`](../meta/document-identifier-root) * [`files/only-allowed-names`](./only-allowed-names) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/tree.md --- # Repository-shape rules Rules about the tree as a whole, rather than the contents of one file. | Rule | Severity | What | | --- | --- | --- | | [`tree/identifier-under-ids`](./identifier-under-ids) | error | Identifier directories must be under `ids/` | | [`tree/no-case-collision`](./no-case-collision) | error | Two names differing only by case cannot both be checked out | | [`tree/only-own-identifier`](./only-own-identifier) | warning | Keep a change to one identifier | Back to the [rule catalogue](../). --- --- url: https://docs.w3id.org/rules/tree/identifier-under-ids.md --- # `tree/identifier-under-ids` **Severity:** error · **Status:** enforced · **Applies to:** `**/.htaccess` ## What An identifier's directory goes under `ids/`. An `.htaccess` anywhere else in the repository is reported. ## Why Only `ids/` is served. `my-project/.htaccess` at the repository root is never read by the server, so `https://w3id.org/my-project/` does not resolve, however correct the rules inside it are. Nothing else catches it. The other identifier rules look under `ids/`, and a change that touches nothing there looks like documentation or tooling work. Pull requests in this shape have been merged and then had to be reverted. It usually comes from a fork made before identifiers moved into `ids/`, or from instructions written before then, including generated ones. ## Wrong ``` my-project/.htaccess my-project/README.md ``` ## Right ``` ids/my-project/.htaccess ids/my-project/README.md ``` ## How to fix Move the directory under `ids/`: ```sh git mv my-project ids/my-project ``` In the GitHub web interface, edit each file and change its path at the top of the editor from `my-project/.htaccess` to `ids/my-project/.htaccess`. If your fork predates the move, update it first, or the move will collide with other changes: see [`git/branch-not-stale`](../git/branch-not-stale). ## How to check Run `w3id-check` with `--rule tree/identifier-under-ids` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule tree/identifier-under-ids ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`tree/only-own-identifier`](./only-own-identifier) * [Creating an identifier](/guides/create-an-id) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/tree/no-case-collision.md --- # `tree/no-case-collision` **Severity:** error · **Status:** enforced · **Applies to:** `ids/**` ## What An identifier directory name must not differ from an existing one only by letter case. ## Why macOS and Windows use case-insensitive filesystems by default. If `ids/Foo/` and `ids/foo/` both exist, the repository **cannot be checked out** on those platforms — Git either merges them into one directory or fails outright. That breaks the working copy for a large share of contributors and maintainers, and the failure is confusing because it has nothing to do with the file they were editing. Linux, where the production server runs, is case-sensitive, so the collision is invisible until somebody clones the repository on a laptop. This has happened here before. The global `ids/.htaccess` carries rules that exist for no reason other than papering over past collisions, and identifiers have had to be renamed after the fact. Lowercase names are preferred beyond just avoiding collisions — they remove the question of which capitalisation a user should type — but that preference is not mechanically checked. ## Wrong Given that `ids/my-project/` already exists: ``` ids/MY-PROJECT/ ← collides ids/My-Project/ ← collides ``` ## Right ``` ids/my-project/ ids/my-project-extended/ ``` Lowercase letters, digits, and hyphens. ## How to fix **Before you choose a name**, check case-insensitively for existing variants: ```sh ls ids/ | grep -i '^my-project$' ``` **If you need to rename** a directory you already committed, do it in two steps so Git records the change on case-insensitive filesystems too: ```sh git mv ids/MyProject ids/myproject-tmp git mv ids/myproject-tmp ids/my-project ``` **If you need both capitalisations to resolve** — an existing IRI used a capitalised form and you are standardising on lowercase — do not create two directories. Keep one, and ask a maintainer about adding a redirect rule for the other form. Those rules live in the global `ids/.htaccess`, which is not a file contributors edit themselves — see [`tree/only-own-identifier`](./only-own-identifier). ## How to check Run `w3id-check` with `--rule tree/no-case-collision` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule tree/no-case-collision ids/my-project ``` The check needs the whole tree to see a collision, so it works from a full checkout even when you scope it to one directory. [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`tree/only-own-identifier`](./only-own-identifier) * [`files/prefer-readme-md`](../files/prefer-readme-md) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/tree/only-own-identifier.md --- # `tree/only-own-identifier` **Severity:** warning · **Status:** enforced · **Applies to:** repository-wide ## What A change that adds or updates an identifier should touch only that identifier's directory under `ids/`. Three things are reported, none of them fatal: * editing **shared infrastructure** — `ids/.htaccess`, `ids/index.html`, `ids/.utils/` — alongside an identifier; * touching **more than one identifier** in the same change; * touching files **outside `ids/`**, which is only a notice. ## Why Three failure modes, all of which have happened here. **Overwriting the repository README.** A project's own README gets uploaded over the repository's, replacing the service documentation with a project description. This is easy to do by accident with GitHub's "Add files via upload", easy to miss in review, and has to be reverted. **Changing someone else's identifier.** Another project's `.htaccess` gets replaced wholesale with an unrelated redirect. From the diff alone that is indistinguishable from a namespace hijack, and the affected maintainer has no warning. **Editing shared infrastructure.** `ids/.htaccess` applies to the entire service and `ids/index.html` is the public homepage. A change there affects every identifier and belongs in its own pull request, reviewed on its own terms. This rule reports rather than blocks, because all three are sometimes deliberate: maintainers edit the rest of the repository as a matter of course, and a documentation fix alongside a redirect is reasonable. The case worth catching is the unintentional one, and the answer to that is visibility. ## Wrong ``` README.md | 164 +++----------------- ids/my-project/.htaccess | 8 ++ ``` ``` ids/other-project/.htaccess | 12 +-- ids/my-project/.htaccess | 8 ++ ``` ## Right ``` ids/my-project/.htaccess | 8 ++ ids/my-project/README.md | 14 ++ ``` ## How to fix Check what your change actually touches before opening the pull request: ```sh git diff --stat upstream/master ``` Everything listed should be under your own directory. If something else appears, revert it: ```sh git checkout upstream/master -- README.md ``` **If you genuinely need to change a directory you do not maintain**, that is allowed — open it as its own pull request, say why, and tag one of the maintainers listed in that directory to approve it. **If you want to change the service documentation**, edit `docs/` and say so. That is a separate pull request too. ## How to check Run `w3id-check` with `--rule tree/only-own-identifier` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. This rule looks at your commits rather than your files, so it needs a base to compare against; narrowing it to a path would not change what it reports. ```sh node tools/check/bin/w3id-check.js --rule tree/only-own-identifier ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`tree/no-case-collision`](./no-case-collision) * [`git/minimal-commits`](../git/minimal-commits) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/no-flag-whitespace.md --- # `htaccess/no-flag-whitespace` **Severity:** error · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What The `[...]` flag list at the end of a `RewriteRule` or `RewriteCond` must not contain whitespace. Flags are separated by commas and nothing else. ## Why Apache does not tolerate spaces in the flag list. It fails to parse the directive, and an unparseable `.htaccess` makes the server return **500 Internal Server Error for every URL under that directory** — not just for the rule that contains the mistake. So a single space takes the whole identifier offline, including sub-paths that have nothing to do with the broken rule. This has needed corrective commits in this repository on several separate occasions, and instances are still present in the tree. ## Wrong ```apache RewriteRule ^ https://example.org/ [R=302, L] RewriteRule ^(.*)$ https://example.org/$1 [NC ,R=302,L] RewriteRule ^vocab$ https://example.org/vocab [R=303, L] ``` ## Right ```apache RewriteRule ^ https://example.org/ [R=302,L] RewriteRule ^(.*)$ https://example.org/$1 [NC,R=302,L] RewriteRule ^vocab$ https://example.org/vocab [R=303,L] ``` ## How to fix Remove every space and tab between `[` and `]`. If your identifier is returning 500 for everything, this is the first thing to check. ## How to check Run `w3id-check` with `--rule htaccess/no-flag-whitespace` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/no-flag-whitespace ids/my-project ``` Note that a plain search for whitespace between brackets is noisy — it matches prose in comments and character classes in patterns. The check reads the parsed flag argument instead, so it reports only genuine flag lists. [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/valid-rewrite-flags`](./valid-rewrite-flags) * [`format/no-bom`](../format/no-bom) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/space-before-flags.md --- # `htaccess/space-before-flags` **Severity:** error · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What The `[...]` flag list at the end of a `RewriteRule` or `RewriteCond` must be separated from the argument before it by a space. Written against the target with nothing in between, it is not a flag list at all — it is part of the target. ## Why Apache splits a directive into arguments on spaces and tabs. A `RewriteRule` takes three: a pattern, a substitution and a flag list. Write the brackets against the substitution and Apache sees only **two** arguments, so there is no flag list, and every flag in it is lost — the status the `R` asked for, the `NE` that would have stopped the URL being escaped, the `L` that would have stopped later rules running. **Nothing about this looks like a failure, which is the point.** Because the substitution is an absolute URL, mod_rewrite still issues an external redirect. The identifier answers `302`, a `curl -I` looks healthy, and the file reads correctly at a glance. What the client actually receives is a redirect to the target with the brackets escaped onto the end as `%5B...%5D` — a URL that does not exist — and at mod_rewrite's default 302 rather than the 303 an ontology IRI meant to send. Compare its neighbour: [`htaccess/no-flag-whitespace`](./no-flag-whitespace) is a space too *many*, and it fails loudly — Apache cannot parse the file and every URL under the identifier returns 500, so somebody notices within the hour. This is a space too *few*, and it fails in silence. Every instance in this repository was committed once and never corrected. No other rule can catch it. Apache parses no flag list, so the checker's parser reports none, so [`htaccess/valid-rewrite-flags`](./valid-rewrite-flags), [`htaccess/uppercase-rewrite-flags`](./uppercase-rewrite-flags) and `htaccess/no-flag-whitespace` all have nothing to look at. ## Wrong ```apache RewriteRule ^vocab\.ttl$ https://example.org/vocab.ttl[R=303,L] RewriteRule ^(.*)$ https://example.org/$1[R=302,NE,L] RewriteCond %{HTTP_ACCEPT} text/turtle[NC] ``` There is a second shape that cannot be shown in a code block, because it looks identical to the correct version: ```apache RewriteRule ^$ https://example.org/ [R=302,L] ``` If the character before `[` is a **no-break space** (`U+00A0`) rather than an ordinary one, Apache does not treat it as a separator and the line has exactly the defect above. This happens when a rule is pasted out of a word processor, a PDF or a rendered web page. The checker names the character when it finds one. To see them yourself: ```sh grep -nP '\xc2\xa0' ids/my-project/.htaccess ``` ## Right ```apache RewriteRule ^vocab\.ttl$ https://example.org/vocab.ttl [R=303,L] RewriteRule ^(.*)$ https://example.org/$1 [R=302,NE,L] RewriteCond %{HTTP_ACCEPT} text/turtle [NC] ``` ## How to fix Put an ordinary space before the `[`. If the gap is a no-break space, delete it and type a real one — adding a second space after it will not help, because the invisible character stays part of the target. Then check the status you meant. A flag list that never applied means the redirect has been running at 302; if the rule says `[R=303,L]`, that 303 has never been served, and anything relying on it — content negotiation for an ontology IRI in particular — has not been working. ## How to check Run `w3id-check` with `--rule htaccess/space-before-flags` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/space-before-flags ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/no-flag-whitespace`](./no-flag-whitespace) — the opposite mistake * [`htaccess/valid-rewrite-flags`](./valid-rewrite-flags) * [`htaccess/avoid-permanent-redirect`](./avoid-permanent-redirect) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/valid-rewrite-flags.md --- # `htaccess/valid-rewrite-flags` **Severity:** error · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What Every name in a flag list must be a mod_rewrite flag, and `[R=...]` must carry an HTTP status code. ## Why Apache rejects a flag list it cannot parse, and a rejected directive makes the whole file unparseable — 500 for every URL under the directory. The failure mode worth knowing about is a typo in the status code. This is real, and currently in the repository: ```apache RewriteRule ^(.*)$ https://example.org/$1 [R=3-7,L] ``` `3-7` was meant to be `307`. ## Wrong ```apache RewriteRule ^a$ https://example.org/ [R=3-7,L] RewriteRule ^b$ https://example.org/ [R=302,Q] ``` ## Right ```apache RewriteRule ^a$ https://example.org/ [R=307,L] RewriteRule ^b$ https://example.org/ [R=302,L] ``` ## How to fix Check the flag names against the [mod_rewrite flag documentation](https://httpd.apache.org/docs/current/rewrite/flags.html). Both the short and long spellings are valid — `[L]` and `[last]`, `[R=302]` and `[redirect=302]` — and Apache matches them case-insensitively, so [`htaccess/uppercase-rewrite-flags`](./uppercase-rewrite-flags) is style rather than correctness. `[R=...]` accepts any HTTP status, not only the 3xx family: given a code outside 300–399, mod_rewrite drops the substitution and ends the request with that status. That is how `[R=406]` works, and why it is not reported here. ## How to check Run `w3id-check` with `--rule htaccess/valid-rewrite-flags` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/valid-rewrite-flags ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/no-flag-whitespace`](./no-flag-whitespace) * [`htaccess/uppercase-rewrite-flags`](./uppercase-rewrite-flags) * [`htaccess/no-406-fallback`](./no-406-fallback) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/no-inline-comment.md --- # `htaccess/no-inline-comment` **Severity:** error · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What A `#` only starts a comment at the beginning of a line. Text after a directive's arguments is not ignored — it is passed to the directive as more arguments. ## Why ```apache RewriteRule ^audio/ - [F] # block audio requests ``` Apache does not read that trailing text as a note to the reader. It reads it as additional arguments to `RewriteRule`, and either misbehaves or refuses to parse the file — which returns 500 for every URL under the directory. ## Wrong ```apache RewriteRule ^audio/ - [F] # block audio requests SetEnvIf Accept ^ FILE_EXT=ttl # the default ``` ## Right ```apache # block audio requests RewriteRule ^audio/ - [F] # the default SetEnvIf Accept ^ FILE_EXT=ttl ``` ## How to fix Move the comment to its own line above the directive. Note that a `#` inside a pattern or a URL is fine — it is only a problem when a separate word beginning with `#` follows the directive's arguments. These are both correct: ```apache RewriteRule ^ont[/-]?prof(#[^/]+)?$ https://example.org/ [R=302,L] RewriteRule ^doc$ https://example.org/page#section [R=302,L] ``` ## How to check Run `w3id-check` with `--rule htaccess/no-inline-comment` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/no-inline-comment ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/no-flag-whitespace`](./no-flag-whitespace) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/uppercase-rewrite-flags.md --- # `htaccess/uppercase-rewrite-flags` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What Write flags as `[R=302,L]`, not `[r=302,l]`. ## Why Apache accepts either spelling, so nothing is broken. Upper case is what the mod_rewrite documentation uses, and it is what a reviewer reading the file expects — a lower-case flag list reads as a typo and costs a moment's double-take every time somebody looks at it. This is the mildest rule in the set. It is here because it is free to fix. ## Wrong ```apache RewriteRule ^$ https://example.org/ [r=302,l] ``` ## Right ```apache RewriteRule ^$ https://example.org/ [R=302,L] ``` ## How to fix Upper-case the flag names. Values keep their own case where they have one. ## How to check Run `w3id-check` with `--rule htaccess/uppercase-rewrite-flags` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/uppercase-rewrite-flags ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/valid-rewrite-flags`](./valid-rewrite-flags) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/rewrite-engine-required.md --- # `htaccess/rewrite-engine-required` **Severity:** error · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What A file containing `RewriteRule` directives must switch the rewrite engine on first: ```apache RewriteEngine on ``` ## Why Without it, Apache ignores every rewrite rule in the file. There is no error and no warning — the rules are simply never consulted, and every request to the identifier falls through to a **404**. The file looks completely reasonable. It contains rules, the rules are correct, and none of them run. `RewriteEngine off` has the same effect and is reported the same way. ## Wrong ```apache RewriteRule ^$ https://example.org/ [R=302,L] ``` ## Right ```apache RewriteEngine on RewriteRule ^$ https://example.org/ [R=302,L] ``` ## How to fix Add `RewriteEngine on` above the first rule. It goes once per file, not once per rule. Files using only `Redirect` or `RedirectMatch` do not need it — those come from a different Apache module — and are not reported. ## How to check Run `w3id-check` with `--rule htaccess/rewrite-engine-required` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/rewrite-engine-required ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`files/no-empty-htaccess`](../files/no-empty-htaccess) * [`htaccess/pattern-relative-to-dir`](./pattern-relative-to-dir) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/pattern-relative-to-dir.md --- # `htaccess/pattern-relative-to-dir` **Severity:** error · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What In a per-directory `.htaccess`, the `RewriteRule` pattern is matched against the request path **relative to the directory containing the file**, with the leading slash stripped. So the pattern must not start with `/`, and must not repeat the identifier's own directory name. ## Why Apache strips the directory prefix before matching. In `ids/my-project/.htaccess`, a request for `https://w3id.org/my-project/vocab/Thing` is matched against `vocab/Thing` — not `/my-project/vocab/Thing`, and not `/vocab/Thing`. A pattern with the wrong shape simply never matches. There is no error, no warning, and no log entry that says what happened; the request falls through to a **404**. That silence is what makes this worth checking mechanically — a `.htaccess` that looks entirely reasonable can be completely inert. Many rules in this repository have one of these two shapes. ## Wrong In `ids/my-project/.htaccess`: ```apache # leading slash -- never matches RewriteRule ^/vocab$ https://example.org/vocab.ttl [R=303,L] # repeats the directory name -- never matches RewriteRule ^my-project/vocab$ https://example.org/vocab.ttl [R=303,L] # both -- never matches RewriteRule ^/my-project/vocab$ https://example.org/vocab.ttl [R=303,L] ``` ## Right ```apache RewriteRule ^vocab$ https://example.org/vocab.ttl [R=303,L] ``` For the identifier root itself — `https://w3id.org/my-project/` — the pattern sees the empty string: ```apache RewriteRule ^$ https://example.org/ [R=303,L] ``` ## How to fix Work out what the path looks like with the directory prefix removed, and match that. | Request | Pattern must match | | --- | --- | | `https://w3id.org/my-project/` | \`\` (empty) | | `https://w3id.org/my-project/vocab` | `vocab` | | `https://w3id.org/my-project/vocab/Thing` | `vocab/Thing` | | `https://w3id.org/my-project/a/b/c` | `a/b/c` | The defensive form `^/?vocab$` also appears in this repository. It works, and is not reported, but it is a workaround for the confusion rather than a fix and it obscures the actual rule. Prefer `^vocab$`. A pattern that merely *starts with the same letters* as its directory — `^my-project\.owl$` in `ids/my-project/` — is a filename, not a repeated prefix, and is not reported. ## How to check Run `w3id-check` with `--rule htaccess/pattern-relative-to-dir` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/pattern-relative-to-dir ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/anchor-patterns`](./anchor-patterns) * [`htaccess/rewrite-engine-required`](./rewrite-engine-required) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/anchor-patterns.md --- # `htaccess/anchor-patterns` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What Where a trailing path segment is optional, make the group optional with `?`. End a pattern with `$` unless you deliberately mean a prefix match. ## Why **A required group matches too little.** `^taxonomy(/.*)$` requires the group, so `https://w3id.org/my-project/taxonomy` — with no trailing slash — does not match at all. Only `taxonomy/` and `taxonomy/something` do. Users hitting the bare form get a 404, and that is exactly the URL they are most likely to type or to have written down. **A missing `$` matches too much.** `^vocab` matches `vocab`, but also `vocabulary`, `vocab-2024`, and `vocabularies/legacy/thing`. Combined with `L`, the over-broad rule fires first and swallows requests a later, more specific rule was supposed to handle. The symptom is a rule further down the file that "never runs" for no visible reason. There is a live example of this in the shared `/people/` namespace. ## Wrong ```apache # does NOT match /my-project/taxonomy RewriteRule ^taxonomy(/.*)$ https://example.org/taxonomy$1 [R=302,L] # matches vocabulary, vocab-old, vocabs/anything ... RewriteRule ^vocab https://example.org/vocab.ttl [R=303,L] ``` ## Right ```apache # matches /taxonomy, /taxonomy/, and /taxonomy/anything RewriteRule ^taxonomy(/.*)?$ https://example.org/taxonomy$1 [R=302,L] RewriteRule ^vocab$ https://example.org/vocab.ttl [R=303,L] ``` A deliberate prefix match is fine when that is what you mean — just be explicit about it: ```apache # everything under /vocab/ goes to the same place, path preserved RewriteRule ^vocab/(.*)$ https://example.org/vocab/$1 [R=302,L] ``` ## How to fix For each rule, write down the exact list of paths it should match and the paths it should not, then check the pattern against both. Pay particular attention to: * the bare form with no trailing slash; * the form with a trailing slash; * a longer identifier that starts with the same letters. ## How to check Run `w3id-check` with `--rule htaccess/anchor-patterns` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/anchor-patterns ids/my-project ``` Only the required-group half is checked. An unanchored pattern is often exactly what the author wanted, and a rule cannot tell the deliberate ones from the accidents — so the `$` advice above is guidance, not a finding. [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/no-greedy-capture`](./no-greedy-capture) * [`htaccess/pattern-relative-to-dir`](./pattern-relative-to-dir) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/no-greedy-capture.md --- # `htaccess/no-greedy-capture` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What `^(.+)/?$` does not do what it appears to. Where you mean "one path segment", write `([^/]+)`. ## Why In `^(.+)/?$`, the `.+` is greedy, so it consumes as much as it can — including the trailing slash — before the optional `/?` gets a chance to match anything. For a request to `vocab/`, the capture `$1` ends up as `vocab/` rather than `vocab`. Substitute that into a URL with its own slash and you get a doubled separator and a broken path: ``` RewriteRule ^(.+)/?$ https://example.org/data/$1/thing.ttl vocab/ → https://example.org/data/vocab//thing.ttl ``` This exact pattern produced malformed ontology redirects in this repository and had to be corrected. ## Wrong ```apache RewriteRule ^(.+)/?$ https://example.org/data/$1/vocab.ttl [R=303,L] ``` ## Right ```apache # one segment, trailing slash optional and not captured RewriteRule ^([^/]+)/?$ https://example.org/data/$1/vocab.ttl [R=303,L] ``` When you genuinely want to pass an arbitrary path straight through, `(.*)` is correct — just do not add slashes around it. That form is **not** reported: ```apache RewriteRule ^(.*)$ https://example.org/base/$1 [R=302,L] ``` ## How to fix Ask what `$1` should contain, then pick the narrowest expression that produces it: | You mean | Use | | --- | --- | | one path segment | `([^/]+)` | | one optional path segment | `([^/]*)` | | a version number | `([0-9]+(\.[0-9]+)*)` | | a known file extension | `(ttl\|jsonld\|rdf\|nt)` | | the whole remaining path, unchanged | `(.*)` | Then check the result for doubled slashes — see [`htaccess/no-double-slash`](./no-double-slash). ## How to check Run `w3id-check` with `--rule htaccess/no-greedy-capture` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/no-greedy-capture ids/my-project ``` Only the `^(.+)/?$` shape is reported. Deciding that some other `(.+)` "should have been" `([^/]+)` needs to know what the author meant. [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/no-double-slash`](./no-double-slash) * [`htaccess/anchor-patterns`](./anchor-patterns) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/escape-literal-dots.md --- # `htaccess/escape-literal-dots` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What A `.` in a `RewriteRule` pattern matches *any single character*. To match a literal dot — in a filename, a version number, or a media type — escape it as `\.`. ## Why `^vocab.ttl$` looks like it matches `vocab.ttl`, and it does. It also matches `vocabXttl`, `vocab-ttl`, `vocab1ttl`, and every other single character in that position. That is rarely what you want, and it produces redirects for URLs you never intended to serve — which then fail at the destination, giving the user a 404 from your server instead of a clean 404 from w3id.org. In a versioned scheme it can silently route one version's requests to another. The same applies inside `RewriteCond` patterns, where media types are the usual victim: `application/ld+json` is a regular expression in which `+` means "one or more of the preceding character". ## Wrong ```apache RewriteRule ^vocab.ttl$ https://example.org/vocab.ttl [R=303,L] RewriteRule ^(1.0.0)$ https://example.org/$1/vocab.ttl [R=303,L] RewriteCond %{HTTP_ACCEPT} application/ld+json ``` ## Right ```apache RewriteRule ^vocab\.ttl$ https://example.org/vocab.ttl [R=303,L] RewriteRule ^(1\.0\.0)$ https://example.org/$1/vocab.ttl [R=303,L] RewriteCond %{HTTP_ACCEPT} application/ld\+json ``` A version pattern in general: ```apache RewriteRule ^([0-9]+(\.[0-9]+)*)$ https://example.org/$1/vocab.ttl [R=303,L] ``` Dots in the **substitution** are literal and need no escaping — only the pattern is a regular expression. ## How to fix Escape every `.` in the pattern that is meant to be a literal dot. A dot that is a deliberate wildcard is not reported: `(.*)`, `(.+)` and `.?` are all doing their job, and so is a dot inside a character class. The check looks only for a dot followed by something shaped like a file extension, which is where the mistake actually lives. ## How to check Run `w3id-check` with `--rule htaccess/escape-literal-dots` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/escape-literal-dots ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/no-greedy-capture`](./no-greedy-capture) * [`htaccess/anchor-patterns`](./anchor-patterns) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/no-open-redirect.md --- # `htaccess/no-open-redirect` **Severity:** error · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What The hostname a redirect sends people to must be written literally in the file. Do not build it out of a value captured from the requested URL. ## Why A rule like this lets the visitor choose where they end up: ```apache RewriteRule ^([^/]+)/schema/(.*)$ https://service.$1/templates/$2 [R=302,L] ``` Request `https://w3id.org/my-project/evil.example/schema/x` and w3id.org redirects to `https://service.evil.example/`. Anyone can craft a w3id.org link that lands on a host they control, with this service's reputation behind it. That is a phishing primitive, and a durable one — w3id.org URLs are meant to be stable for decades and are widely trusted in exactly the communities that would follow one without looking. Using the captured value in the **path** is fine. It is only the hostname that matters. ## Wrong ```apache # visitor picks the parent domain RewriteRule ^([^/]+)/schema/(.*)$ https://service.$1/t/$2 [R=302,L] # visitor picks the whole host RewriteRule ^go/([^/]*)/(.*)$ https://$1/$2 [R=302,L] # capture appended to a hostname: .evil.example makes it a different host RewriteRule ^data(.*)$ https://files.example.org$1 [R=302,L] ``` ## Right ```apache RewriteRule ^([^/]+)/schema/(.*)$ https://service.example.org/$1/t/$2 [R=302,L] ``` The hostname is fixed; the captured values go in the path. ## How to fix Write the destination hostname out in full. If you genuinely need to send requests to several different hosts, enumerate them: ```apache RewriteRule ^alpha/(.*)$ https://alpha.example.org/$1 [R=302,L] RewriteRule ^beta/(.*)$ https://beta.example.org/$1 [R=302,L] ``` ## How to check Run `w3id-check` with `--rule htaccess/no-open-redirect` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/no-open-redirect ids/my-project ``` The check reasons about where the capture lands and what it can contain, so a capture whose character class cannot produce a dot or a slash — and therefore cannot change the host — is not reported. [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/https-target`](./https-target) * [`htaccess/allowed-directives`](./allowed-directives) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/https-target.md --- # `htaccess/https-target` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What Redirect targets should use `https://`. ## Why w3id.org is HTTPS-only precisely so that identifier resolution cannot be tampered with in transit. A secure redirect to an insecure destination throws that away at the last step: an attacker on the path to your server can substitute the ontology, the context file, or the credential schema being fetched, and the client has no way to tell. It also produces mixed-content failures. A page loaded over HTTPS cannot fetch `http://` resources — browsers block the request outright — so an `http://` target simply does not work for browser-based clients, whatever the security argument. Well over a thousand rules in this repository still target `http://`. Most of those destinations now support HTTPS and the rule was just never updated. ## Wrong ```apache RewriteRule ^(.*)$ http://example.org/vocab/$1 [R=302,L] ``` ## Right ```apache RewriteRule ^(.*)$ https://example.org/vocab/$1 [R=302,L] ``` ## How to fix Check whether the destination supports HTTPS — most do now: ```sh curl -sI https://example.org/vocab/vocab.ttl | head -n 1 ``` If it does, change the scheme. If it does not, that host is not a suitable target for a permanent identifier; get a certificate on it (Let's Encrypt is free) or move the content somewhere that has one. A bare IP address is worse again: it cannot present a valid TLS certificate for a hostname, ties the identifier to one specific machine, and will break when the host is renumbered — which, over the decades an identifier is meant to last, it will be. Put a real hostname in front of it and point the identifier at that. ## How to check Run `w3id-check` with `--rule htaccess/https-target` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/https-target ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/github-raw-target`](./github-raw-target) * [`htaccess/no-open-redirect`](./no-open-redirect) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/github-raw-target.md --- # `htaccess/github-raw-target` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What When redirecting to a file hosted on GitHub: * use `raw.githubusercontent.com`, not `github.com/.../blob/...`, unless the target is documentation for people to read; * do not include a `refs/heads/` path segment. ## Why **`/blob/` serves a web page, not a file.** `https://github.com/user/repo/blob/main/vocab.ttl` returns an HTML document with syntax highlighting, navigation, and a comment box. A triple store fetching your ontology IRI gets that HTML and fails to parse it. The redirect looks right, the URL opens fine in a browser, and every machine client breaks. More than a hundred rules in this repository still do this. The raw equivalent — `https://raw.githubusercontent.com/user/repo/main/vocab.ttl` — returns the file itself. **`refs/heads/` is redundant.** GitHub's web interface sometimes produces raw URLs containing `refs/heads/main`. These work today, but the extra segment is an implementation detail of GitHub's routing rather than a documented URL shape, and it makes the target harder to read. ## Wrong ```apache RewriteRule ^vocab\.ttl$ https://github.com/user/repo/blob/main/vocab.ttl [R=303,L] RewriteRule ^vocab\.ttl$ https://raw.githubusercontent.com/user/repo/refs/heads/main/vocab.ttl [R=303,L] ``` ## Right ```apache # pinned to a release tag -- stable RewriteRule ^vocab\.ttl$ https://raw.githubusercontent.com/user/repo/v1.2.0/vocab.ttl [R=303,L] ``` Or, if you deliberately want "whatever is current": ```apache RewriteRule ^vocab\.ttl$ https://raw.githubusercontent.com/user/repo/main/vocab.ttl [R=303,L] ``` ## How to fix Convert a blob URL to a raw one: ``` https://github.com/user/repo/blob/main/path/vocab.ttl https://raw.githubusercontent.com/user/repo/main/path/vocab.ttl ``` Then verify what actually comes back: ```sh curl -sIL https://raw.githubusercontent.com/user/repo/main/vocab.ttl \ | grep -iE '^(HTTP|content-type)' ``` Three things this rule deliberately does not tell you to do. **Documentation is the exception.** A `/blob/` link to Markdown, or another format GitHub renders as a page, is not reported. That page is for people, and the raw file is `text/plain` markup. A common shape sends RDF clients to the raw vocabulary and everyone else to its rendered documentation: ```apache RewriteCond %{HTTP_ACCEPT} text/turtle RewriteRule ^core$ https://raw.githubusercontent.com/user/repo/main/core.ttl [R=303,L] RewriteRule ^core$ https://github.com/user/repo/blob/main/docs/core.md [R=303,L] ``` **Branch names move**, and a tag does not — but a branch target is the right answer when you want whatever is current, so preferring a tag is advice rather than a rule, and is not reported. **GitHub Pages is often a better target than raw files at all.** `raw.githubusercontent.com` serves everything as `text/plain`, so a correctly negotiated request for `text/turtle` still arrives with the wrong content type and some clients reject it. With Pages you control the content type and get a real hostname you can move later without touching this repository. ## How to check Run `w3id-check` with `--rule htaccess/github-raw-target` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/github-raw-target ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/https-target`](./https-target) * [`files/only-allowed-names`](../files/only-allowed-names) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/no-rawgit.md --- # `htaccess/no-rawgit` **Severity:** error · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What A redirect target must not use `rawgit.com` or `cdn.rawgit.com`. Use jsDelivr for files, or GitHub Pages for pages meant to be read. ## Why RawGit has shut down. Its [repository](https://github.com/rgrove/rawgit) is archived, with a notice to use jsDelivr instead. * **`rawgit.com` returns 404.** An identifier redirecting there does not resolve. * **`cdn.rawgit.com` only forwards to jsDelivr.** It works today because a shut-down service still answers, and it can stop without notice. jsDelivr serves the same files from GitHub, and it is what RawGit's notice points to. It also sends a real content type for RDF — `text/turtle`, `application/rdf+xml`, `application/ld+json` — which `raw.githubusercontent.com` does not. ## Wrong ```apache RewriteRule ^vocab$ https://rawgit.com/user/repo/master/vocab.ttl [R=303,L] RewriteRule ^vocab$ https://cdn.rawgit.com/user/repo/v1.0/vocab.ttl [R=303,L] ``` ## Right ```apache RewriteRule ^vocab$ https://cdn.jsdelivr.net/gh/user/repo@v1.0/vocab.ttl [R=303,L] ``` ## How to fix Move the ref after an `@`: ``` https://rawgit.com/user/repo/REF/path/file.ttl https://cdn.rawgit.com/user/repo/REF/path/file.ttl https://cdn.jsdelivr.net/gh/user/repo@REF/path/file.ttl ``` The finding names the exact URL to use. Check that it returns the file and the content type you expect: ```sh curl -sIL https://cdn.jsdelivr.net/gh/user/repo@v1.0/vocab.ttl \ | grep -iE '^(HTTP|content-type)' ``` Two things to know about jsDelivr: * **It serves HTML as `text/plain`**, so a browser shows the source. For a documentation page, use the repository's GitHub Pages site instead. * **It caches a branch**, so a push to `master` can take a while to appear. A URL naming a tag or a commit always serves the same file. ## How to check Run `w3id-check` with `--rule htaccess/no-rawgit` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/no-rawgit ids/my-project ``` This is a critical rule, so `w3id-check --triage` lists every identifier in the tree that still uses RawGit. [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/github-raw-target`](./github-raw-target) * [`htaccess/https-target`](./https-target) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/no-double-slash.md --- # `htaccess/no-double-slash` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What The URL produced by a redirect must not contain `//` after the scheme. ## Why `https://example.org/base//vocab.ttl` is not the same URL as `https://example.org/base/vocab.ttl`. Many servers treat the two differently: some normalise, some 404, some serve a directory listing, and some redirect again. Static hosts such as GitHub Pages and S3 are among the strict ones. It is also a different IRI to every RDF tool that sees it, which matters when the identifier is what names your terms. The redirect looks correct in the `.htaccess`, and the failure only shows up at the destination — which makes it slow to diagnose, because the reported symptom is "w3id.org is broken" when in fact w3id.org did exactly what it was told. The cause is almost always a slash on both sides of a capture: the pattern captures a leading or trailing `/`, and the substitution supplies one too. See [`htaccess/no-greedy-capture`](./no-greedy-capture) for the greedy version of the same mistake. This has needed corrective commits here on at least five occasions. ## Wrong ```apache # capture includes the leading slash, substitution adds another RewriteRule ^(/.*)$ https://example.org/base/$1 [R=302,L] # /vocab → https://example.org/base//vocab # literal doubled slash RewriteRule ^(.*)$ https://example.org/contexts//$1 [R=302,L] ``` ## Right ```apache RewriteRule ^(.*)$ https://example.org/base/$1 [R=302,L] # vocab → https://example.org/base/vocab RewriteRule ^(.*)$ https://example.org/contexts/$1 [R=302,L] ``` ## How to fix Trace one concrete request through the rule by hand. Write down the path as the pattern sees it — relative to the directory, no leading slash, see [`htaccess/pattern-relative-to-dir`](./pattern-relative-to-dir) — work out what each capture contains, and substitute it in. Then confirm with a real request: ```sh curl -sI http://localhost:8080/my-project/vocab | grep -i '^location:' ``` ## How to check Run `w3id-check` with `--rule htaccess/no-double-slash` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/no-double-slash ids/my-project ``` The check reads the literal text of the target, so it catches the doubled slash you can see. The version that only appears once a capture is substituted needs tracing by hand. [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/no-greedy-capture`](./no-greedy-capture) * [`htaccess/pattern-relative-to-dir`](./pattern-relative-to-dir) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/no-self-redirect.md --- # `htaccess/no-self-redirect` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What A redirect target should be the final destination, not another w3id.org URL. ## Why Every hop costs the client a round trip, and a hop back through w3id.org costs the service one too — the request arrives, is redirected, comes back, and is redirected again. At the scale this service runs, that is real traffic for no benefit. Worse, a rule that targets the identifier it is defined in **loops**. The browser follows the redirect back to the same URL, matches the same rule, and keeps going until it gives up. The identifier is unusable and the failure looks like a browser problem rather than a configuration one. ## Wrong ```apache # in ids/my-project/.htaccess -- redirects to itself, forever RewriteRule ^$ https://w3id.org/my-project [R=302,L] ``` ```apache # an extra round trip through the service for no reason RewriteRule ^vocab$ https://w3id.org/other-project/vocab [R=302,L] ``` ## Right ```apache RewriteRule ^vocab$ https://example.org/other/vocab [R=302,L] ``` If one identifier really should resolve to the same place as another, point both at the destination. ## How to fix Follow the chain to its end and target that: ```sh curl -sIL https://w3id.org/other-project/vocab | grep -i '^location:' ``` If the identifier has genuinely been superseded and you want the old name to keep working, a redirect within the service is acceptable — but write it as a relative target so it does not leave the server: ```apache RewriteRule ^(.*)$ /new-project/$1 [R=302,L] ``` ## How to check Run `w3id-check` with `--rule htaccess/no-self-redirect` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/no-self-redirect ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/avoid-permanent-redirect`](./avoid-permanent-redirect) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/avoid-permanent-redirect.md --- # `htaccess/avoid-permanent-redirect` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What Use `302` for an ordinary redirect, or `303` when the identifier names a thing and the target is a representation of it. Avoid `301` and `308`. ## Why A permanent redirect tells every cache along the way that the answer will never change, and caches are entitled to honour that indefinitely. That makes a mistake unretractable. Correct the rule and the clients that already saw the 301 keep going to the old target — for as long as their cache decides, which may be forever. There is nothing this service can do about it, and no way to tell how many are affected. For an identifier meant to last decades, the ability to correct a target later is the whole point. A 302 costs nothing and keeps it. Hundreds of rules in this repository use `301`. ## Wrong ```apache RewriteRule ^(.*)$ https://example.org/$1 [R=301,L] Redirect 301 /my-project https://example.org/ ``` ## Right ```apache RewriteRule ^(.*)$ https://example.org/$1 [R=302,L] ``` For an ontology IRI, where the identifier names a concept and the target is a document describing it: ```apache RewriteRule ^vocab\.ttl$ https://example.org/vocab.ttl [R=303,L] ``` ## How to fix Change the status code. If an identifier really has moved permanently and you want callers to update their references, say so in the `README.md` — that reaches humans, which a status code does not. ## How to check Run `w3id-check` with `--rule htaccess/avoid-permanent-redirect` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/avoid-permanent-redirect ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/https-target`](./https-target) * [`htaccess/no-406-fallback`](./no-406-fallback) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/allowed-directives.md --- # `htaccess/allowed-directives` **Severity:** error · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What An identifier's `.htaccess` configures redirects. Directives that make the server execute code, serve local files, proxy requests, or change access control are not accepted. ## Why This file runs on a server shared with every other identifier on the service. A directive that reaches outside the redirect use case is a change to infrastructure that two thousand other people depend on, arriving through a pull request that looks like a routine namespace addition. The `[P]` proxy flag deserves a specific mention. It does not redirect — it makes w3id.org **fetch** the target and serve the response as its own. That turns the service into an open proxy, puts its IP address behind whatever the target does, and means the service is now serving content it never agreed to host. ## Wrong ```apache Options +ExecCGI AddHandler cgi-script .cgi RewriteEngine on RewriteRule ^run$ https://example.org/thing [P] ``` ## Right ```apache RewriteEngine on RewriteRule ^run$ https://example.org/thing [R=302,L] ``` ## How to fix Remove the directive. If you believe your identifier genuinely needs one of them, open an issue describing what you are trying to do rather than putting it in a pull request — the answer is usually that the behaviour belongs on the server that hosts your content. The refused set is: `RewriteMap`, `Include`, `IncludeOptional`, `AddHandler`, `SetHandler`, `Action`, `Script`, `Alias`, `ScriptAlias`, the `Auth*` family, `Satisfy`, the `php_*` family, `Options +ExecCGI`, `Options +Includes`, `Options All`, and the `[P]` flag. ## How to check Run `w3id-check` with `--rule htaccess/allowed-directives` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/allowed-directives ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/no-open-redirect`](./no-open-redirect) * [`htaccess/no-options-directive`](./no-options-directive) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/valid-cors-header.md --- # `htaccess/valid-cors-header` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What If you copy an `.htaccess` from elsewhere in this repository and it contains an `Access-Control-Allow-Headers` line ending in `If-Modified$`, delete the line. More generally: CORS headers belong on the server that actually serves your content, not on the redirect. ## Why A single `.htaccess` in this repository once gained this line: ```apache Header set Access-Control-Allow-Headers DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified$ ``` It is **truncated mid-token**. The value should end `If-Modified-Since,Cache-Control`; instead it stops after `If-Modified` and picks up a stray `$`. Over the years since, that line was copied, verbatim and unexamined, into **hundreds of files** — while barely any file here contains a correct `If-Modified-Since`. It is the clearest illustration in this codebase of why the existing tree is not a style guide: a string appearing everywhere is evidence that it was easy to copy, not that it was ever right. **CORS on a redirect does not do what people think.** When a browser makes a cross-origin request to `https://w3id.org/my-vocab/` and gets a 303, it follows the redirect and then applies the CORS check to the **final response** — the one from your server. Headers set on the redirect do not authorise reading the data. Whatever host actually serves `vocab.ttl` is what needs to send `Access-Control-Allow-Origin`. ## Wrong ```apache Header set Access-Control-Allow-Origin * Header set Access-Control-Allow-Headers DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified$ RewriteEngine on RewriteRule ^ https://example.org/vocab.ttl [R=303,L] ``` ## Right ```apache RewriteEngine on RewriteRule ^ https://example.org/vocab.ttl [R=303,L] ``` And on **your** server, where `vocab.ttl` is served: ``` Access-Control-Allow-Origin: * ``` ## How to fix Delete the line. Then check that whatever serves your content sends CORS headers: ```sh curl -sI -H 'Origin: https://example.com' https://example.org/vocab.ttl \ | grep -i 'access-control' ``` If that returns nothing, configure CORS there — that is the fix that actually makes browser-based clients work. A bare `Header set Access-Control-Allow-Origin "*"` on the redirect is not reported. It is unnecessary but harmless, and only the malformed `Allow-Headers` line is a mistake. ## How to check Run `w3id-check` with `--rule htaccess/valid-cors-header` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/valid-cors-header ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/allowed-directives`](./allowed-directives) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/no-406-fallback.md --- # `htaccess/no-406-fallback` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**/.htaccess` ## What Prefer ending a content negotiation block with an unconditional redirect to your HTML documentation, rather than a rule that returns `406 Not Acceptable`. ## Why Answering 406 is a defensible choice. The identifier genuinely cannot serve what was asked for, and saying so is honest — some authors mean exactly that, which is why this is a warning and not an error. One shape is worse than the rest, and it is the one that gets copied from ontology templates: ```apache RewriteCond %{HTTP_ACCEPT} .+ RewriteRule ^$ - [R=406,L] ``` The condition `.+` matches any non-empty `Accept` header. Every HTTP client sends one — browsers, `curl`, triple stores, crawlers. So this rule matches essentially every request that reaches it, and returns an error instead of a redirect. It appears to work while you are testing, because your earlier rules catch the formats you thought to check. It fails for everything you did not: a browser sending an `Accept` header your `text/html` condition does not quite match, a client sending a wildcard media range, a tool sending `application/*`. Those users get a 406 and have no idea why. This has returned 406 for real requests here, in an identifier that repeated the fallback several times over. Hundreds of identifiers carry the catch-all shape. And a 406 is usually just a worse answer. If somebody asks for a format you do not publish, giving them your human-readable documentation tells them what does exist. An error tells them nothing. ## Wrong ```apache RewriteEngine on RewriteCond %{HTTP_ACCEPT} text/turtle RewriteRule ^$ https://example.org/vocab.ttl [R=303,L] RewriteCond %{HTTP_ACCEPT} text/html RewriteRule ^$ https://example.org/index.html [R=303,L] # Everything else gets an error RewriteCond %{HTTP_ACCEPT} .+ RewriteRule ^$ - [R=406,L] ``` ## Right ```apache RewriteEngine on RewriteCond %{HTTP_ACCEPT} text/turtle RewriteRule ^$ https://example.org/vocab.ttl [R=303,L] RewriteCond %{HTTP_ACCEPT} application/ld\+json RewriteRule ^$ https://example.org/vocab.jsonld [R=303,L] # Default: anything else, including browsers, gets the documentation. # No condition, so it always matches. RewriteRule ^$ https://example.org/index.html [R=303,L] ``` ## How to fix Delete the 406 rule and make sure the last rule in the block is unconditional and points at something useful. Then check the cases the 406 was hiding: ```sh curl -sI -H 'Accept: */*' http://localhost:8080/my-vocab/ curl -sI http://localhost:8080/my-vocab/ ``` Neither should return 406. ## How to check Run `w3id-check` with `--rule htaccess/no-406-fallback` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule htaccess/no-406-fallback ids/my-project ``` The check distinguishes the two cases. A 406 behind a condition that matches any `Accept` header gets the stronger message; a 406 behind a specific condition is reported as the narrower, considered choice it probably is. Neither is an error. [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`htaccess/valid-rewrite-flags`](./valid-rewrite-flags) * [`htaccess/avoid-permanent-redirect`](./avoid-permanent-redirect) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/htaccess/no-options-directive.md --- # `htaccess/no-options-directive` **Severity:** warning · **Status:** proposed · **Applies to:** `ids/**/.htaccess` ::: info Documented, not yet checked This rule is **proposed**. No automated check reports it, and the questions below need answering before one should. See [How to check](#how-to-check). ::: ## What `Options` directives appear in most of the `.htaccess` files in this repository — `Options +FollowSymLinks`, `Options -MultiViews`, `Options -Indexes` — almost always copied from an example alongside lines that were needed. ## Why **They are mostly not needed.** Your identifier redirects to a URL somewhere else. `Options` controls how Apache serves files out of the directory — symlink following, directory listings, implicit content negotiation over local files — and none of that happens, because the request never gets as far as being served. The rewrite rule fires first. **`-Indexes` has actively caused a 403.** One identifier here returned `403 Forbidden` for its bare URL — the one without a trailing slash — because of it. The interaction is subtle: 1. For a request to `/my-project` (no trailing slash), mod_rewrite **declines** to run the rules in `ids/my-project/.htaccess`. This is deliberate behaviour in Apache: when the request resolves to exactly the directory holding the `.htaccess`, per-directory rewriting is skipped. 2. Normally `mod_dir` then redirects `/my-project` to `/my-project/` with a 301, and on that second request the rules do run. That extra hop is correct and expected. 3. But if directory listings are also disabled and the auto-redirect is suppressed, Apache has no valid response left to give, and answers 403. So the directive added to "tidy things up" is the thing that broke the most important URL of the identifier. **Do not try to remove the extra hop.** The `DirectorySlash Off` + `RewriteOptions AllowNoSlash` combination that eliminates it is what re-arms this trap for whoever edits the file next. One cached redirect is not worth it. ## Wrong ```apache Options +FollowSymLinks Options -MultiViews Options -Indexes RewriteEngine on RewriteRule ^(.*)$ https://example.org/$1 [R=302,L] ``` ## Right ```apache RewriteEngine on RewriteRule ^(.*)$ https://example.org/$1 [R=302,L] ``` ## How to fix Delete the `Options` lines. Then check the bare URL still behaves: ```sh curl -sI http://localhost:8080/my-project # expect 301 → /my-project/ curl -sI http://localhost:8080/my-project/ # expect your redirect ``` ## How to check Not automated yet, deliberately. Three questions need answering first, and each changes what the check should say: * **`-MultiViews`** may be load-bearing for some content-negotiation approaches, where Apache's implicit negotiation would otherwise interfere. Nothing in the tree demonstrably relies on that, but this is the directive with a stated justification, so a rule that flags it needs to know when it is right. Worth knowing before that research starts: MultiViews is **enabled** on the deployed document root, and about a third of the files here switch it off — so these are not lines reacting to nothing. A handful more have the directive commented out, which suggests somebody tried it and backed it out, or copied a template that had already given up on it. * **`-Indexes`** probably has no per-directory justification at all, and would be better handled once, globally, in the server configuration. If that happened, the per-directory copies become redundant rather than wrong — a different message, and arguably a different severity. * **`+FollowSymLinks`** is in most files and nobody remembers why, or whether it does anything on this service. That needs establishing before the check tells people to remove it. Until those are settled, this page is guidance for reviewers rather than a gate. The related directives that are unambiguously refused — the ones that make the server execute code or proxy requests — are covered by [`htaccess/allowed-directives`](./allowed-directives), which is enforced. [Testing your changes](/guides/testing) covers what the tool does check, and what is worth checking by hand. ## See also * [`htaccess/allowed-directives`](./allowed-directives) * [`files/only-allowed-names`](../files/only-allowed-names) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/format.md --- # File format rules Encoding, line endings and whitespace. Everything here applies to the text files an identifier directory may hold: the `.htaccess`, and the README in whatever name and format it carries — `README.md`, `README.MD`, a bare `README`, or any of the other formats GitHub renders. That last part is deliberate. If a README name is accepted by [`files/only-allowed-names`](../files/only-allowed-names), it is checked here too; a file the repository is willing to keep should not escape these rules by being spelled unusually. | Rule | Severity | What | | --- | --- | --- | | [`format/no-bom`](./no-bom) | error | No byte order mark — it makes Apache return 500 | | [`format/no-crlf`](./no-crlf) | warning | Unix line endings, not CRLF | | [`format/final-newline`](./final-newline) | warning | End the file with a newline | | [`format/no-trailing-whitespace`](./no-trailing-whitespace) | warning | No trailing whitespace that does nothing | | [`format/no-excessive-blank-lines`](./no-excessive-blank-lines) | warning | No long runs of blank lines, and none at the start or end | Only [`format/no-bom`](./no-bom) can break an identifier. The rest are diff hygiene and readability, and your editor can handle most of them for you — see the note about EditorConfig on any of those pages. [`format/no-excessive-blank-lines`](./no-excessive-blank-lines) is the exception, and says so on its own page: it is fixed by hand. Back to the [rule catalogue](../). --- --- url: https://docs.w3id.org/rules/format/no-bom.md --- # `format/no-bom` **Severity:** error · **Status:** enforced · **Applies to:** `ids/**` ## What Files must be UTF-8 encoded with **no byte order mark**. ## Why A byte order mark is three invisible bytes at the start of the file. Apache reads them as part of the first directive, which usually makes that directive unparseable — and an unparseable `.htaccess` returns **500 for every URL under the directory**. The symptom is an identifier that is completely broken while the file looks perfect in every editor. This has happened here. BOMs are added by some Windows editors, by Notepad, and by a few tools that "helpfully" fix encodings. You will not see one by looking. No file in the repository currently has one. This rule exists so that stays true: of all the whitespace and encoding problems in these files, this is the only one that takes an identifier off the air. ## Wrong ``` $ head -c 3 ids/my-project/.htaccess | xxd 00000000: efbb bf ... ``` Those three bytes are a BOM. ## Right ``` $ file ids/my-project/.htaccess ids/my-project/.htaccess: ASCII text ``` ## How to fix Check for one: ```sh grep -rlI $'\xef\xbb\xbf' ids/my-project/ ``` Remove it: ```sh sed -i '1s/^\xef\xbb\xbf//' ids/my-project/.htaccess ``` Better still, install [EditorConfig](https://editorconfig.org/) support in your editor — most modern editors have it built in, some need a plugin — and the repository's [`.editorconfig`](https://github.com/perma-id/w3id.org/blob/master/.editorconfig) will handle this for you on every file you save, along with the other whitespace rules. ## How to check Run `w3id-check` with `--rule format/no-bom` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule format/no-bom ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`format/no-crlf`](./no-crlf) * [`htaccess/no-flag-whitespace`](../htaccess/no-flag-whitespace) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/format/no-crlf.md --- # `format/no-crlf` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**` ## What Files use **LF** line endings, not the CRLF pair that Windows tools write by default. ## Why Mixed with the LF endings used everywhere else, a CRLF file shows **every one of its lines as changed** in a diff — including the ones you did not touch. That buries the actual change and makes the history harder to read later. In an `.htaccess` there is a second, sharper problem: a stray carriage return at the end of a substitution URL becomes part of the redirect target. The rule looks right and sends clients to a URL with an invisible control character on the end. Plenty of files in this repository already have CRLF endings. ## Wrong ``` $ file ids/my-project/.htaccess ids/my-project/.htaccess: ASCII text, with CRLF line terminators ``` ## Right ``` $ file ids/my-project/.htaccess ids/my-project/.htaccess: ASCII text ``` ## How to fix Convert the file: ```sh sed -i 's/\r$//' ids/my-project/.htaccess ``` Stop it happening again — this tells Git to store LF and convert on checkout: ```sh git config core.autocrlf input ``` Better still, install [EditorConfig](https://editorconfig.org/) support in your editor — most modern editors have it built in, some need a plugin — and the repository's [`.editorconfig`](https://github.com/perma-id/w3id.org/blob/master/.editorconfig) will handle this for you on every file you save, along with the other whitespace rules. ## How to check Run `w3id-check` with `--rule format/no-crlf` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule format/no-crlf ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`format/final-newline`](./final-newline) * [`format/no-bom`](./no-bom) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/format/final-newline.md --- # `format/final-newline` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**` ## What Every file ends with a newline character. ## Why Git marks a file without one `\ No newline at end of file`, and the next change that appends a line has to add the newline to your last line first — so your line shows as modified in a diff you had nothing to do with. It also means two files cannot be concatenated cleanly, and some shell tools silently drop the final line. None of this breaks an identifier. It is diff hygiene, which is why it is a warning: worth fixing while you are here, not worth blocking a redirect over. Hundreds of files in this repository are missing one. ## Wrong ``` $ tail -c 1 ids/my-project/.htaccess | xxd 00000000: 5d ] ``` The file ends with `]`, not a newline. ## Right ``` $ tail -c 1 ids/my-project/.htaccess | xxd 00000000: 0a . ``` ## How to fix ```sh printf '\n' >> ids/my-project/.htaccess ``` Better still, install [EditorConfig](https://editorconfig.org/) support in your editor — most modern editors have it built in, some need a plugin — and the repository's [`.editorconfig`](https://github.com/perma-id/w3id.org/blob/master/.editorconfig) will handle this for you on every file you save, along with the other whitespace rules. ## How to check Run `w3id-check` with `--rule format/final-newline` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule format/final-newline ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`format/no-trailing-whitespace`](./no-trailing-whitespace) * [`format/no-crlf`](./no-crlf) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/format/no-trailing-whitespace.md --- # `format/no-trailing-whitespace` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**` ## What Lines must not end in spaces or tabs that have no effect. In `.htaccess` and plain text that means **all** of it — neither format has a line-break idiom, so every trailing space is dead weight. In Markdown it means the whitespace that cannot be doing anything: a single trailing space, a trailing tab, or whitespace on an otherwise-empty line. Two or more trailing spaces is Markdown's hard-line-break idiom, and this rule leaves it alone — see [`markdown/prefer-list-over-line-breaks`](../markdown/prefer-list-over-line-breaks) for what to do about that instead. ## Why Trailing whitespace is invisible in most editors and reads as noise in every diff that touches the line. It is not a functional problem in these files. The distinction Markdown gets is the point of the rule. A **single** trailing space in Markdown does nothing at all — a line break needs two or more — so it is pure noise. Reporting the two-space form alongside it would be wrong: those lines render as their author intended, and many of them in this repository are doing real work in maintainer blocks. ## Wrong In `.htaccess`, any of it: ```apache RewriteEngine on RewriteRule ^$ https://example.org/ [R=302,L]··· ``` In Markdown, a lone trailing space: ```markdown Contacts:· ``` ## Right ```apache RewriteEngine on RewriteRule ^$ https://example.org/ [R=302,L] ``` ## How to fix Find it: ```sh grep -nP '[ \t]+$' ids/my-project/.htaccess ``` Remove it: ```sh sed -i 's/[[:space:]]*$//' ids/my-project/.htaccess ``` Better still, install [EditorConfig](https://editorconfig.org/) support in your editor — most modern editors have it built in, some need a plugin — and the repository's [`.editorconfig`](https://github.com/perma-id/w3id.org/blob/master/.editorconfig) will handle this for you on every file you save, along with the other whitespace rules. ## How to check Run `w3id-check` with `--rule format/no-trailing-whitespace` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule format/no-trailing-whitespace ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`markdown/prefer-list-over-line-breaks`](../markdown/prefer-list-over-line-breaks) * [`format/final-newline`](./final-newline) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/format/no-excessive-blank-lines.md --- # `format/no-excessive-blank-lines` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**` ## What Three thresholds, because the middle of a file and its two edges are different situations: | Where | Allowed | | --- | --- | | Between lines | up to **3** blank lines in a row | | At the start | **none** | | At the end | the final newline, and **one** blank line past it | ## Why A blank line or two between groups of directives earns its place — it shows which rules belong together, which is the one thing an `.htaccess` cannot say any other way. Three is generous. Past that the space is no longer separating anything, and the file becomes longer than the window it is being read in: a reviewer scrolls, loses the previous rule from view, and has to scroll back. **The two edges are a different matter, because there is nothing on the far side to separate.** A blank line at the top of a file is a whole line of nothing before the file has begun; all it does is push the first real line further from the top of the screen. At the bottom, the file is *supposed* to end with a newline after its last line — see [`format/final-newline`](./final-newline) — so one blank line past that is a single character of overshoot and not worth mentioning. Two or more is somebody having left a gap behind. None of this breaks anything. Apache ignores blank lines and so does Markdown; this is a rule about the file being readable by the next person, not about whether the identifier resolves. ## Wrong ```apache RewriteEngine on RewriteRule ^vocab$ https://example.org/vocab.ttl [R=303,L] RewriteRule ^spec$ https://example.org/spec.html [R=303,L] ``` Four blank lines separating two rules that are already one line apart. ## Right ```apache RewriteEngine on # Ontology RewriteRule ^vocab$ https://example.org/vocab.ttl [R=303,L] RewriteRule ^vocab.ttl$ https://example.org/vocab.ttl [R=303,L] # Documentation RewriteRule ^spec$ https://example.org/spec.html [R=303,L] ``` One blank line does the grouping, and a comment says what the group is. ## How to fix Delete the extra lines. To see where they are: ```sh cat -A ids/my-project/.htaccess | grep -n '^\$' ``` `cat -A` marks the end of every line with `$`, so a blank line shows as a bare `$` and the line numbers tell you which ones are consecutive. Squeeze runs of blank lines down to one, throughout the file: ```sh cat -s ids/my-project/.htaccess ``` Read the result before saving it over the original — `cat -s` is indiscriminate, and if you were using two or three blank lines deliberately to group rules it will flatten that too. Installing [EditorConfig](https://editorconfig.org/) support in your editor is worth doing for the other whitespace rules — the repository's [`.editorconfig`](https://github.com/perma-id/w3id.org/blob/master/.editorconfig) trims trailing whitespace and fixes line endings on save. It will **not** help here: `insert_final_newline` guarantees a file *ends* with a newline but does not remove extra blank lines before it. This one is done by hand. ## How to check Run `w3id-check` with `--rule format/no-excessive-blank-lines` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule format/no-excessive-blank-lines ids/my-project ``` A run is reported once, at its first line, rather than once per blank line. Content inside a fenced code block is not counted. A README quoting an example `.htaccess` contains whatever it is quoting, and this rule is about the layout of the file itself — which is how the "Wrong" example above can appear on this page at all. A run of whitespace-only lines is also reported by [`format/no-trailing-whitespace`](./no-trailing-whitespace), once per line. Those are two different things to say about the same lines — that the run is too long, and that the lines are not even empty — rather than the same complaint twice. [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`format/final-newline`](./final-newline) * [`format/no-trailing-whitespace`](./no-trailing-whitespace) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/markdown.md --- # Markdown rules Rules for the README files in an identifier directory. | Rule | Severity | What | | --- | --- | --- | | [`markdown/prefer-list-over-line-breaks`](./prefer-list-over-line-breaks) | notice | A stack of manual line breaks reads better as a list | Whitespace and encoding in Markdown files are covered by the [file format rules](../format/). Back to the [rule catalogue](../). --- --- url: https://docs.w3id.org/rules/markdown/prefer-list-over-line-breaks.md --- # `markdown/prefer-list-over-line-breaks` **Severity:** notice · **Status:** enforced · **Applies to:** `ids/**/*.md` ## What Where several consecutive lines are held apart by a manual line break — two or more trailing spaces, a trailing backslash, or a trailing `
` — a bulleted list is usually the better shape. ## Why Maintainer and contact blocks are overwhelmingly written as a stack of lines glued together by invisible markup. It renders correctly, so this is a suggestion rather than a fault. It is worth making because the markup cannot be seen. The next person to edit the block has no way to tell what is holding it together, and deleting a marker by accident silently reflows three lines into one paragraph — which is exactly the sort of thing nobody notices in a diff. A list survives editing. The rule only fires on a run of two or more broken lines in the same block. A single hard break inside a paragraph is a deliberate typographic choice, and a block that is already a list is left alone even when its items carry markers. Hundreds of blocks in this repository are in this shape. ## Wrong ```markdown Some Person
Email: someone@example.org
GitHub: https://github.com/someone
``` ```markdown Some Person \ ORCID: https://orcid.org/0000-0000-0000-0000 \ GitHub: [@someone](https://github.com/someone) ``` ## Right ```markdown - Some Person - Email: someone@example.org - GitHub: [@someone](https://github.com/someone) ``` ## How to fix Put `- ` in front of each line and delete the trailing marker. If the block has a heading above it, leave the heading where it is. To find them: ```sh grep -nE '( {2,}|\\|
)$' ids/my-project/README.md ``` ## How to check Run `w3id-check` with `--rule markdown/prefer-list-over-line-breaks` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule markdown/prefer-list-over-line-breaks ids/my-project ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`format/no-trailing-whitespace`](../format/no-trailing-whitespace) * [`meta/maintainer-github-username`](../meta/maintainer-github-username) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/git.md --- # Pull request shape rules These look at the commits rather than the files. They only run when there is a range to compare against, so they are reported on a pull request and not by a run scoped to a directory. | Rule | Severity | What | | --- | --- | --- | | [`git/minimal-commits`](./minimal-commits) | warning | One identifier change should be one commit | | [`git/no-merge-commits`](./no-merge-commits) | warning | Rebase; do not merge master into your branch | | [`git/descriptive-commit-message`](./descriptive-commit-message) | warning | Name the identifier, not the filename | | [`git/no-session-link`](./no-session-link) | warning | No links to a private agent session | | [`git/branch-not-stale`](./branch-not-stale) | notice | Start from a recent master | Back to the [rule catalogue](../). --- --- url: https://docs.w3id.org/rules/git/minimal-commits.md --- # `git/minimal-commits` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**` ## What A change to an identifier should be a short series of commits — ideally one. In particular, one file should not be changed by several commits in a row. Only commits that touch an identifier are counted. Work elsewhere in the repository — the documentation, the tooling — is not what this rule is about, and a branch that touches no identifier is never reported however many commits it has. ## Why Adding an identifier is one change. Split across six commits it is six things to read, and the reviewer has to reconstruct what the final state is before they can judge it. Maintaining the rest of the repository is a different activity. There the history is the useful artifact — what changed, and why, in order — and flattening it loses something worth keeping. The usual cause is the GitHub web editor, which commits once per save. Adding a directory through the browser typically produces something like "Create README.md", "Create .htaccess", "Update README.md", "Update README.md again" — four commits for one small change, three of which are corrections to the first. Squashing is not about tidiness. It is about the reviewer being able to see what you are actually asking for. ## Wrong ``` $ git log --oneline upstream/master..HEAD 87738437 Update README.md again ee4102a0 Update README.md d3389e3a Create .htaccess c885985a Create README.md ``` ## Right ``` $ git log --oneline upstream/master..HEAD a1b2c3d4 Add redirect for my-project ``` ## How to fix The simplest way is to tick **Please squash my commits when merging** in the pull request description, and a maintainer will squash them when merging. Your fork then needs [resyncing](/faq#do-i-have-to-squash-my-commits) before your next change. To squash them yourself: ```sh git branch backup-my-change # a copy to go back to if this goes wrong git rebase -i upstream/master ``` Mark every commit after the first as `squash`, save, and write one message for the combined change. Check the result with `git show` before you force-push your branch: ```sh git push --force-with-lease ``` Partway through, `git rebase --abort` returns to where you started. Afterwards, your original commits are still on `backup-my-change`; delete it with `git branch -D backup-my-change` once the pull request is merged. ## How to check Run `w3id-check` with `--rule git/minimal-commits` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. This rule looks at your commits rather than your files, so it needs a base to compare against; narrowing it to a path would not change what it reports. ```sh node tools/check/bin/w3id-check.js --rule git/minimal-commits ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`git/no-merge-commits`](./no-merge-commits) * [`git/descriptive-commit-message`](./descriptive-commit-message) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/git/no-merge-commits.md --- # `git/no-merge-commits` **Severity:** warning · **Status:** enforced · **Applies to:** repository-wide ## What A pull request should not contain merge commits. ## Why GitHub's "Sync fork" button merges the upstream branch into yours. That pulls every commit it caught up on into your pull request, so a one-line redirect change arrives carrying a hundred unrelated commits and a merge commit on top. Hundreds of such merges have reached `master` in this repository, most of them the `Merge branch 'perma-id:master' into ...` shape that button produces. They add no content and make the change hard to read. Rebasing puts your work on top of the current master instead, so the pull request contains your commits and nothing else. ## Wrong ``` $ git log --oneline upstream/master..HEAD 9f3c1a2 Merge branch 'perma-id:master' into my-branch 7b2d4e8 Add redirect for my-project ``` ## Right ``` $ git log --oneline upstream/master..HEAD 7b2d4e8 Add redirect for my-project ``` ## How to fix ```sh git fetch upstream git branch backup-my-change # a copy to go back to if this goes wrong git rebase upstream/master git push --force-with-lease ``` If you have not added the upstream remote: ```sh git remote add upstream https://github.com/perma-id/w3id.org.git ``` If a rebase goes wrong partway through, `git rebase --abort` returns to where you started. Your original commits are still on `backup-my-change` afterwards. Often the simplest recovery is to start a fresh branch from `upstream/master` and re-apply your change by hand — it is usually two files. Delete the copy with `git branch -D backup-my-change` once the pull request is merged. ## How to check Run `w3id-check` with `--rule git/no-merge-commits` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. This rule looks at your commits rather than your files, so it needs a base to compare against; narrowing it to a path would not change what it reports. ```sh node tools/check/bin/w3id-check.js --rule git/no-merge-commits ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`git/branch-not-stale`](./branch-not-stale) * [`git/minimal-commits`](./minimal-commits) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/git/descriptive-commit-message.md --- # `git/descriptive-commit-message` **Severity:** warning · **Status:** enforced · **Applies to:** repository-wide ## What Replace the message GitHub fills in for you. Say which identifier changed. ## Why Every identifier in this repository has an `.htaccess` and most have a `README.md`, so "Update .htaccess" says nothing at all about what happened. More than a thousand commits here already carry that exact message. The history is how anyone works out later why a redirect points where it does. A message that names the identifier makes `git log --grep` useful; one that does not makes a decade of commits into an undifferentiated wall. About one commit in six arrives with the default message. ## Wrong ``` Update .htaccess Create README.md Add files via upload ``` ## Right ``` Add redirect for my-project my-project: point vocab at the 1.2.0 release my-project: add maintainer GitHub handle ``` ## How to fix In the GitHub web interface, the commit message box sits below the editor and is pre-filled — replace the text before committing. Locally: ```sh git status # anything staged is added to the amended commit git commit --amend -m "Add redirect for my-project" ``` This rewords only the most recent commit. For an older one, use the `git rebase -i` in [`git/minimal-commits`](./minimal-commits#how-to-fix) and mark it `reword`. For a message already pushed, amend and force-push: ```sh git push --force-with-lease ``` ## How to check Run `w3id-check` with `--rule git/descriptive-commit-message` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. This rule looks at your commits rather than your files, so it needs a base to compare against; narrowing it to a path would not change what it reports. ```sh node tools/check/bin/w3id-check.js --rule git/descriptive-commit-message ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`git/minimal-commits`](./minimal-commits) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/git/no-session-link.md --- # `git/no-session-link` **Severity:** warning · **Status:** enforced · **Applies to:** repository-wide ## What A commit message should not contain a link to the coding-agent session that produced it, such as the trailer Claude Code adds: ``` Claude-Session: https://claude.ai/code/session_01AbCdEf... ``` ## Why **Nobody else can open it.** The session is private to whoever ran it, so reviewers following the link, and anyone reading the history later, reach a page that does not load. **A merged commit keeps it for good.** History here is public and is not rewritten, so the link outlasts any reason it had to be there. A pull request description is a better place for it, if anywhere: it can be edited later. What the link does not cover is how the change was made, and that is worth recording. A `Co-Authored-By` line naming the agent says so, as does an `Assisted-by` line in the style the [Linux kernel uses](https://docs.kernel.org/process/coding-assistants.html#attribution). Neither is reported. ## Wrong ``` my-project: add vocabulary redirect Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AbCdEf... ``` ## Right ``` my-project: add vocabulary redirect Co-Authored-By: Claude ``` Or: ``` my-project: add vocabulary redirect Assisted-by: Claude ``` ## How to fix The simplest way is to tick **Please squash my commits when merging** in the pull request description; the maintainer merging it can then leave the line out of the squashed message. To remove it yourself, for the most recent commit: ```sh git commit --amend # delete the line in the editor git push --force-with-lease ``` For an older commit, `git rebase -i upstream/master`, mark it `reword`, and delete the line; see [`git/minimal-commits`](./minimal-commits#how-to-fix) for the same steps with a backup branch. To stop Claude Code adding it, set `attribution.sessionUrl` to `false` in one of its settings files, such as `~/.claude/settings.json`: ```json { "attribution": { "sessionUrl": false } } ``` That keeps the `Co-Authored-By` line, and also leaves the link out of pull request descriptions. See the [settings reference](https://code.claude.com/docs/en/settings-reference#attribution-sessionurl). ## How to check Run `w3id-check` with `--rule git/no-session-link` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. This rule looks at your commits rather than your files, so it needs a base to compare against; narrowing it to a path would not change what it reports. ```sh node tools/check/bin/w3id-check.js --rule git/no-session-link ``` [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`git/descriptive-commit-message`](./descriptive-commit-message) * [`git/minimal-commits`](./minimal-commits) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/git/branch-not-stale.md --- # `git/branch-not-stale` **Severity:** notice · **Status:** enforced · **Applies to:** repository-wide ## What A branch should start from a reasonably recent `master`. ## Why A fork that was cloned once and never updated diverges quietly. The change itself is usually still fine — identifier directories rarely conflict with each other — but three things go wrong: * the pull request is harder to review, because the diff is computed against a base nobody is looking at any more; * the contributor reaches for "Sync fork", which merges rather than rebases and drags a merge commit plus everything since into the pull request. See [`git/no-merge-commits`](./no-merge-commits); * the checks can miss things. The pull request's own checks run on the change merged into the current `master`, so they use the current rules however old the branch is — but GitHub runs no checks at all on a pull request with a merge conflict, and the checker you run yourself is whatever version your branch has. This is a notice, not a warning. Being behind is not itself a mistake, and it never blocks anything — it is here because it is the step before the mistake. ## Wrong Nothing, exactly. This is advice. ## Right ```sh $ git fetch upstream $ git rev-list --count HEAD..upstream/master 3 ``` ## How to fix From the command line: ```sh git fetch upstream git branch backup-my-change # a copy to go back to if this goes wrong git rebase upstream/master git push --force-with-lease # a plain push is rejected; do not pull instead ``` Git's message for the rejected push suggests `git pull`. After a rebase, that either stops with an error or merges the old commits back in, adding the merge commit [`git/no-merge-commits`](./no-merge-commits) reports. Delete the copy with `git branch -D backup-my-change` once the pull request is merged. For a fork you have not touched in a long time, it is often quicker to start a new branch from `upstream/master` and re-apply your change, keeping the old branch until the new one is pushed. In the browser: * **Before you start a change**, open your fork on GitHub and use **Sync fork**, then make your edit. If you have not committed to your fork's default branch, this only catches it up. * **Once the pull request is open**, use the **Update branch** button in the merge section near the bottom of the pull request, and choose **Update with rebase** from its dropdown. GitHub shows the button when there are no merge conflicts and the branch is behind. The plain **Update branch** and **Update with merge commit** add a merge commit, which [`git/no-merge-commits`](./no-merge-commits) reports. ## How to check Run `w3id-check` with `--rule git/branch-not-stale` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. This rule looks at your commits rather than your files, so it needs a base to compare against; narrowing it to a path would not change what it reports. ```sh node tools/check/bin/w3id-check.js --rule git/branch-not-stale ``` The threshold is configurable in `.w3id-check.yaml` under `options.git/branch-not-stale.maxBehind`. [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`git/no-merge-commits`](./no-merge-commits) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/meta.md --- # Metadata rules Information about an identifier, and about the rule set itself. | Rule | Severity | What | | --- | --- | --- | | [`meta/document-identifier-root`](./document-identifier-root) | warning | Claim the root of an identifier with a maintainer record | | [`meta/maintainer-github-username`](./maintainer-github-username) | warning | Record who maintains the identifier, with a GitHub username | | [`meta/rule-docs-exist`](./rule-docs-exist) | warning | Every rule has a page and every page has a rule | Back to the [rule catalogue](../). --- --- url: https://docs.w3id.org/rules/meta/document-identifier-root.md --- # `meta/document-identifier-root` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**` ## What The root directory of an identifier should record what the identifier is for and who maintains it. Either of these satisfies the rule: * a `README.md` at the root of the identifier, or * maintainer details in the comments of the root `.htaccess`. Both are in use across the tree and neither is preferred. The rule asks only *where* the information is, never what shape it takes. ## Why Recording a maintainer at the root **claims the identifier**. Ownership here is inherited downward: a claim at `ids/my-project/` covers everything beneath it, which is how requests to change an identifier have always been assessed. Without one, nothing marks the rest of that tree as taken, and a later contribution can add `ids/my-project/something-else/` under a different owner with nothing to say it should not. That matters more as the handling of contributions is automated. A person reviewing a pull request might recognise that a directory belongs to somebody else. A machine has only what is written down. The identifiers here are also meant to outlive the people who create them. Somebody arriving at `ids/my-project/` a decade from now needs to know what it resolves to and who to ask about it. ## Ownership is hierarchical, in both directions **Downward:** a claim at the root covers the whole tree, so sub-directories need nothing of their own. `ids/my-project/v1/` with no README is perfectly correct when `ids/my-project/` is documented. **Upward: it does not work.** A README three levels down documents *that* part of the tree. It says nothing about the identifier as a whole, and it leaves the root unclaimed. **A sub-directory may add its own maintainers**, and several identifiers work this way. They are *additional* — read together with the root, not instead of it: ``` ids/my-project/ ├── .htaccess ├── README.md ← claims the identifier: Firstname Lastname └── datasets/ ├── .htaccess └── README.md ← adds a second maintainer for the datasets ``` This is supported, not merely tolerated. If you see this warning on an identifier that already has a README in a sub-directory, **leave that README where it is** and add a claim at the root as well. Moving it up to silence the warning destroys a record of who looks after that part of the tree. ## Wrong Documented, but the root is unclaimed: ``` ids/my-project/ ├── .htaccess └── datasets/ ├── .htaccess └── README.md ← names a maintainer for datasets only ``` Nothing anywhere: ``` ids/my-project/ └── .htaccess ← redirects, but says nothing about who owns this ``` ## Right A README at the root: ``` ids/my-project/ ├── .htaccess └── README.md ``` Or the same information in the `.htaccess` comments, with no README at all: ```apache # my-project -- permanent identifiers for the My Project vocabulary # # homepage: # - https://example.org/my-project/ # maintainers: # - @exampleuser RewriteEngine on RewriteRule ^$ https://example.org/my-project/ [R=302,L] ``` ## How to fix Add a `README.md` at the root of the identifier: ```markdown # my-project Permanent identifiers for the My Project vocabulary. redirects to . ## Contact - Firstname Lastname, , GitHub: [@exampleuser](https://github.com/exampleuser) ``` Keep it to what the identifier is and who looks after it. Project documentation belongs with the project — see [`files/only-allowed-names`](../files/only-allowed-names). Or, if you would rather not add a file, put the same details in comments at the top of the root `.htaccess`, in the style shown above. An identifier that exists only to group sub-identifiers has no redirect of its own to serve. An `.htaccess` there holding nothing but comments is a legitimate way to claim it — [`files/no-empty-htaccess`](../files/no-empty-htaccess) makes an exception for exactly this case. ## How to check Run `w3id-check` with `--rule meta/document-identifier-root` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule meta/document-identifier-root ids/my-project ``` A namespace whose sub-names belong to different people has no single maintainer to record at its root. Those are listed under `options: meta/document-identifier-root: sharedNamespaces` in `.w3id-check.yaml`, which is a stopgap until a metadata format can express ownership per entry. [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`meta/maintainer-github-username`](./maintainer-github-username) — what the maintainer record should contain; this rule only asks where it is * [`files/prefer-readme-md`](../files/prefer-readme-md) * [`files/no-empty-htaccess`](../files/no-empty-htaccess) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/meta/maintainer-github-username.md --- # `meta/maintainer-github-username` **Severity:** warning · **Status:** enforced · **Applies to:** `ids/**` ## What Every identifier must record who maintains it, including at least one **GitHub username**. It can go in `.htaccess` comments, in a `README.md`, or both. Either is acceptable — there is no requirement to have a `README.md` if the `.htaccess` comments carry the information. This rule reads the whole identifier, so a record anywhere in it counts. [`meta/document-identifier-root`](./document-identifier-root) is the companion rule that asks for one at the root specifically, since that is what claims the identifier. A sub-directory may record **additional** maintainers for its own part of the tree, and several identifiers do. Those are read together with the root's, not instead of them — never move or delete a deeper record to satisfy either rule. ## Why This is the single most frequent review comment on this repository. Identifiers are meant to last for decades, and the people maintaining them change. Without a contact: * a reviewer cannot tell whether a future pull request touching your directory is legitimate or a namespace hijack; * nobody can tag you to approve a change to your own space; * when your redirect target breaks, nobody can tell you; * when the service needs to reach identifier owners, you are unreachable. A GitHub username specifically, because that is where changes to this repository happen. An email address alone does not let a reviewer confirm that the account opening a pull request is entitled to make the change. ## Wrong No contact information at all: ```apache RewriteEngine on RewriteRule ^ https://example.org/ [R=302,L] ``` Contact information with no GitHub account: ```apache # My Project # Contact: someone@example.org RewriteEngine on RewriteRule ^ https://example.org/ [R=302,L] ``` ## Right In `.htaccess` comments: ```apache # # /my-project/ # # Permanent identifiers for the My Project vocabulary. # https://w3id.org/my-project/ redirects to https://example.org/ # # ## Contact # This space is administered by: # # Firstname Lastname # firstname@example.org # GitHub username: exampleuser RewriteEngine on RewriteRule ^(.*)$ https://example.org/$1 [R=302,L] ``` Or in `README.md`: ```markdown ## Contact - Firstname Lastname, , GitHub: [@exampleuser](https://github.com/exampleuser) ``` More than one maintainer is better than one. An organisation or team account is fine, and is more durable than an individual. ## How to fix Add the block above. If you are taking over an existing identifier, update the contact rather than appending to it, and make sure the outgoing maintainer is visible on the pull request. ## How to check Run `w3id-check` with `--rule meta/maintainer-github-username` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. ```sh node tools/check/bin/w3id-check.js --rule meta/maintainer-github-username ids/my-project ``` The check recognises the many shapes this information takes across the repository — `GitHub username: name`, `(GitHub: name)`, a bare `@name`, a line starting `Username: name`, a profile URL, a Markdown link. It is deliberately lenient, and reports only when it can find no plausible username anywhere in the identifier. A structured format may replace it later. [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [`meta/document-identifier-root`](./document-identifier-root) * [`markdown/prefer-list-over-line-breaks`](../markdown/prefer-list-over-line-breaks) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/rules/meta/rule-docs-exist.md --- # `meta/rule-docs-exist` **Severity:** warning · **Status:** enforced · **Applies to:** `docs/rules/**` ## What The rule registry and this catalogue must agree: * every rule the checker implements has a page at `docs/rules/.md`; * every page marked `status: enforced` corresponds to a rule that exists; * a page's `id` matches its path. A page marked `status: proposed` is exempt from the second point — that is what `proposed` means. ## Why Every finding the checker reports carries a link to `https://w3id.org/docs/rules/`. If the page is missing, the reader follows that link to a 404 at the exact moment they were told to go and read something. The reverse matters too. A page claiming `status: enforced` tells a contributor a check will catch this before review, and that promise should be true. This is the rule that keeps the two halves of this repository — the checker in `tools/check/` and the catalogue in `docs/rules/` — from drifting apart. They were written separately, which is exactly the situation that produces drift. ## Wrong A rule with no page: ``` tools/check/src/rules/htaccess/.js ← exists docs/rules/htaccess/.md ← missing ``` A page whose id disagrees with its path: ``` docs/rules/htaccess/https-target.md --- id: target-https ← does not match the path --- ``` ## Right ``` tools/check/src/rules/htaccess/https-target.js docs/rules/htaccess/https-target.md with id: htaccess/https-target ``` ## How to fix Adding a rule means adding its page in the same change, and adding it to the catalogue table and the site sidebar. Removing a check without removing the page means changing the page's `status` to `proposed`. ## How to check Run `w3id-check` with `--rule meta/rule-docs-exist` to check this rule on its own; without it the tool runs every rule, as the pull request checks do. This one is about the checker's own documentation rather than an identifier, so it runs over the whole tree. ```sh node tools/check/bin/w3id-check.js --all --rule meta/rule-docs-exist ``` It is silent when `docs/rules/` is absent, so the checker still works in a tree without the documentation. [Testing your changes](/guides/testing) covers installing the tool, and what else is worth checking by hand. ## See also * [The rule catalogue](../) * [Report a false positive or a missing check](https://github.com/perma-id/w3id.org/issues) --- --- url: https://docs.w3id.org/faq.md --- # FAQ ## About the service ### What is w3id.org, in one sentence? A permanent URL redirection service: you claim a path under `https://w3id.org/`, and you control where requests for it are sent. See [Overview](/overview/). ### Is the W3C involved? No. The "w3" in the domain name stands for "World Wide Web". Other than hosting the [Permanent Identifier Community Group](https://www.w3.org/community/perma-id/), the World Wide Web Consortium has no involvement in the support or management of this service. An identifier under w3id.org is not a W3C standard, recommendation, or publication, and carries no endorsement. ### Does it cost anything? No. The service is run by a consortium of organisations that have pledged to keep it operating. See [Overview — Who runs it](/overview/#who-runs-it). ### How long will my identifier last? The intent is decades — "as long as the Web is around". Identifiers are not deleted once they are in use, because deleting one breaks other people's data. That commitment runs both ways: do not claim an identifier you are not prepared to look after. See [Purpose — The commitment](/overview/purpose#the-commitment). ## Getting an identifier ### How do I get one? Open a pull request adding a directory under `ids/`. See [Creating an identifier](/guides/create-an-id). If you would rather not use Git, you can ask on the [mailing list](https://lists.w3.org/Archives/Public/public-perma-id/) instead — include the URL you want, where it should redirect to, and the status code. ### Can I host my ontology file here? No. This service redirects; it does not serve files. Publish your ontology, schema, or context somewhere you control and point the identifier at it. This is the most common out-of-scope request. See [`files/only-allowed-names`](/rules/files/only-allowed-names) and [Scope](/overview/scope#it-is-not-a-file-host). ### Can I have a short or generic name? Probably not. Names that are single common words, that could be confused with an existing identifier or a known organisation, or that look like speculative claims are declined. Ask on the [mailing list](https://lists.w3.org/Archives/Public/public-perma-id/) before opening a pull request if you are unsure. See [Scope — Naming](/overview/scope#naming). ### Can I reserve a name for a future project? No. Claiming a directory is a long-term commitment, not a reservation. Come back when the project exists and the content is live. ### Is there a list of reserved names? No. Check [`ids/`](https://github.com/perma-id/w3id.org/tree/master/ids) for what is taken — including case variants, since `Foo` and `foo` cannot coexist. See [`tree/no-case-collision`](/rules/tree/no-case-collision). ### Can I use w3id.org as a URL shortener? No. The service exists so that identifiers embedded in data keep resolving. If nothing is going to embed the identifier, this is the wrong tool. See [Scope](/overview/scope#it-is-not-a-cdn-or-a-general-purpose-url-shortener). ## Pull requests ### When will my change go live? After a maintainer reviews and merges your pull request, the change is deployed to the service and is normally live shortly afterwards. Check it with: ```sh curl -sIL https://w3id.org/my-project/ ``` ### How long does review take? It varies with maintainer availability. You can make it faster by opening a small, single-purpose pull request that follows the [rules](/rules/) — most delay comes from review round-trips over things that were avoidable. ### Why was my pull request sent back? The most frequent reasons, in rough order: 1. No maintainer GitHub username — [`meta/maintainer-github-username`](/rules/meta/maintainer-github-username) 2. Content files committed — [`files/only-allowed-names`](/rules/files/only-allowed-names) 3. Files changed outside your directory — [`tree/only-own-identifier`](/rules/tree/only-own-identifier) 4. The file is not named `.htaccess` — [`files/only-allowed-names`](/rules/files/only-allowed-names) 5. Untested rules that do not work 6. Uninformative commit messages, or a long unsquashed history See the [rule catalogue](/rules/) for the full list. ### Do I have to squash my commits? A new identifier should ideally be one commit. If you are not comfortable squashing, tick the box in the pull request template and a maintainer will squash when merging. Your fork then needs resyncing before your next change. The squashed commit is a new one, so your original commits are not in `master`, and a next change built on top of them would carry them along. In the browser, delete the merged branch (the merged pull request offers a button for it), use **Sync fork** on your fork's page, and start the next change on a new branch. If you committed straight to your fork's default branch and it still shows commits ahead of `perma-id/w3id.org:master`, the dependable fix in the browser is to delete the fork and fork again. From the command line: ```sh git fetch upstream git switch master git status # commit anything listed: reset discards it git log upstream/master..master # dropped next: only the squashed commits? git branch old-master # a copy to go back to git reset --hard upstream/master git push --force-with-lease origin master ``` Once your next change is in, delete the copy with `git branch -D old-master`. To avoid needing this at all, make each change on its own branch. GitHub's web editor offers "Create a new branch for this commit and start a pull request" when you commit. ### Why does my commit message matter? `Update .htaccess` is by a wide margin the most common commit message in this repository, on well over a thousand commits — more than twice the next most common. When something breaks years later, that history is what somebody has to read. Include your project name and what changed. ### Can I change someone else's identifier? Only with the listed maintainer's involvement. Open it as its own pull request, explain why, and tag a maintainer from that directory to approve it. See [Maintaining an identifier](/guides/maintain-an-id#who-may-change-an-identifier). ### Why didn't any automated check catch my mistake? Probably one of three reasons. The rule may be **documented but not mechanized** — a rule page marked `proposed` in the [catalogue](/rules/) has no check behind it yet. The mistake may be **outside what any checker can know**. Nothing can tell that your redirect points at the wrong place, only that it is well-formed. Whether the target is the one you meant is [yours to verify](/guides/testing). Or the check may only look at **what your change touched**. The checker reports what your own diff is responsible for, so a pre-existing problem elsewhere in the repository will not appear. Note also that most of the identifiers already in this repository predate any automated checking and have never been checked at all. A rule being enforced today says nothing about whether the existing tree satisfies it — which is one more reason not to copy a neighbouring `.htaccess` as a template. ## Redirects and HTTP ### Which status code should I use? **302** for ordinary redirects. **303** for content-negotiated ontology IRIs, where the identifier names a concept and you redirect to a document about it. **Avoid 301.** Browsers and caches may honour it indefinitely and never re-check, so a 301 you got wrong cannot be fully retracted. On a service built for identifiers that live for decades, that is a bad trade for a marginal caching benefit. ### Why am I getting a 500? Your `.htaccess` has a syntax error, and Apache is refusing to parse it. Note that a 500 affects **every** URL under the directory, not just the broken rule. Check first for a space inside the flag brackets — [`htaccess/no-flag-whitespace`](/rules/htaccess/no-flag-whitespace) — and for a byte order mark at the start of the file — [`format/no-bom`](/rules/format/no-bom). ### Why am I getting a 403? Most likely an `Options -Indexes` directive interacting badly with a bare identifier URL. Remove the `Options` lines; you do not need them. See [`htaccess/no-options-directive`](/rules/htaccess/no-options-directive). ### Why doesn't my rule match anything? Almost always because the pattern includes a leading slash or the identifier's own directory name. The pattern is matched against the path **relative to your directory**. See [`htaccess/pattern-relative-to-dir`](/rules/htaccess/pattern-relative-to-dir). ### Why does `https://w3id.org/my-project` redirect twice? Because it is a real directory. Apache first redirects the bare form to `https://w3id.org/my-project/` with a 301, and your rules run on the second request. This is normal and expected. Do not try to eliminate it with `DirectorySlash Off` or `Options -Indexes` — that is how you get a 403. See [`htaccess/no-options-directive`](/rules/htaccess/no-options-directive). ### Why is my client getting HTML instead of Turtle? Two likely causes: * your redirect target is a `github.com/.../blob/...` URL, which serves a web page rather than the file — see [`htaccess/github-raw-target`](/rules/htaccess/github-raw-target); * your content negotiation rule ordering puts a broad match above the specific ones — see [Content negotiation](/guides/content-negotiation#_1-order-most-specific-first-default-last). ### Why is my client getting a 406? You have a `[R=406]` catch-all fallback that is matching every request. Remove it and end with an unconditional default. See [`htaccess/no-406-fallback`](/rules/htaccess/no-406-fallback). ### Can I use plain HTTP? No. The service is HTTPS-only, end to end. Your redirect targets should be HTTPS too — see [`htaccess/https-target`](/rules/htaccess/https-target). ### Can I restrict access to my identifier? No. There is no authentication or access control. Everything on the service is public. See [Scope](/overview/scope#it-is-not-a-dynamic-application-platform). ### Can w3id.org proxy or mirror my content? No. It issues redirects; it does not fetch or serve content. ## Maintenance ### My content moved. What do I do? Add a rule for the new location, keep the old paths resolving, and test both. See [Maintaining an identifier](/guides/maintain-an-id#zero-downtime-updates). ### How do I transfer maintainership? Update the contact details in a pull request that both the outgoing and incoming maintainer are visible on, and do it while the outgoing maintainer is still reachable. See [Maintaining an identifier](/guides/maintain-an-id#transferring-maintainership). ### Can I delete my identifier? Identifiers are not deleted — removing one turns it into a 404 for everyone still using it. Instead, return `410 Gone` or redirect to a page explaining what happened. See [Maintaining an identifier](/guides/maintain-an-id#retiring-an-identifier). ### Can I reuse an abandoned identifier for my project? No. The old identifier is still in other people's data, and repointing it means they silently start receiving something unrelated. Pick a different name. ## Other ### Where do I ask something not covered here? The [public-perma-id@w3.org mailing list](https://lists.w3.org/Archives/Public/public-perma-id/). For problems with a specific identifier, the [issue tracker](https://github.com/perma-id/w3id.org/issues). ### How do I join the consortium? Introduce yourself on the mailing list, then open an issue titled *Seeking to join the W3ID Consortium*. See [Overview](/overview/#joining-the-consortium). ### I am an AI agent. What should I read? [`AGENTS.md`](https://github.com/perma-id/w3id.org/blob/master/AGENTS.md) in the repository, then [Scope](/overview/scope) and the [rule catalogue](/rules/). Machine-readable versions of this site are at [`/llms.txt`](https://w3id.org/docs/llms.txt) and [`/llms-full.txt`](https://w3id.org/docs/llms-full.txt), and any page is available as raw Markdown by appending `.md` to its URL. --- --- url: https://docs.w3id.org/news.md --- # News Infrequent announcements about w3id.org: changes to the service, to the rules a contribution has to follow, to the tooling, and to who runs it. This is not a changelog of individual identifiers. Those are visible in the [repository history](https://github.com/perma-id/w3id.org/commits/master). ## Posts ## Subscribe * [RSS](/news/rss.xml) — `https://docs.w3id.org/news/rss.xml` * [Atom](/news/atom.xml) — `https://docs.w3id.org/news/atom.xml` --- --- url: https://docs.w3id.org/rules/htaccess.md --- # `.htaccess` rules Everything about the redirect rules themselves: how they are written, and where they point. ## Syntax These break the file. Apache refuses to parse an invalid directive, and an unparseable `.htaccess` returns 500 for every URL under the directory. | Rule | Severity | What | | --- | --- | --- | | [`htaccess/no-flag-whitespace`](./no-flag-whitespace) | error | No spaces inside the `[...]` flag list | | [`htaccess/space-before-flags`](./space-before-flags) | error | A space *before* the `[...]`, or it is part of the target | | [`htaccess/valid-rewrite-flags`](./valid-rewrite-flags) | error | Flag names must be ones mod_rewrite knows | | [`htaccess/no-inline-comment`](./no-inline-comment) | error | `#` only starts a comment at the start of a line | | [`htaccess/uppercase-rewrite-flags`](./uppercase-rewrite-flags) | warning | Write flags in upper case | ## Rules that never match These do not break anything. They simply never fire, and the request falls through to a 404 with nothing in the logs to say why. | Rule | Severity | What | | --- | --- | --- | | [`htaccess/rewrite-engine-required`](./rewrite-engine-required) | error | `RewriteRule` does nothing without `RewriteEngine on` | | [`htaccess/pattern-relative-to-dir`](./pattern-relative-to-dir) | error | No leading slash, no repeated directory name | | [`htaccess/anchor-patterns`](./anchor-patterns) | warning | Make an optional trailing group optional | | [`htaccess/no-greedy-capture`](./no-greedy-capture) | warning | `^(.+)/?$` swallows the slash | | [`htaccess/escape-literal-dots`](./escape-literal-dots) | warning | `.` is a wildcard; escape a literal dot | ## Redirect targets | Rule | Severity | What | | --- | --- | --- | | [`htaccess/no-open-redirect`](./no-open-redirect) | error | Never build the target hostname from the request | | [`htaccess/https-target`](./https-target) | warning | Redirect to `https://` | | [`htaccess/github-raw-target`](./github-raw-target) | warning | Raw file URLs, not `/blob/` pages | | [`htaccess/no-rawgit`](./no-rawgit) | error | RawGit has shut down; use jsDelivr | | [`htaccess/no-double-slash`](./no-double-slash) | warning | No `//` in the produced URL | | [`htaccess/no-self-redirect`](./no-self-redirect) | warning | Point at the destination, not back at w3id.org | | [`htaccess/avoid-permanent-redirect`](./avoid-permanent-redirect) | warning | A 301 cannot be taken back | ## Directives and negotiation | Rule | Severity | What | | --- | --- | --- | | [`htaccess/allowed-directives`](./allowed-directives) | error | No code execution, proxying, or access control | | [`htaccess/valid-cors-header`](./valid-cors-header) | warning | Do not copy the truncated CORS line | | [`htaccess/no-406-fallback`](./no-406-fallback) | warning | Ending negotiation in 406 answers nobody | | [`htaccess/no-options-directive`](./no-options-directive) | *proposed* | `Options` directives — open questions | Back to the [rule catalogue](../).