diff --git a/.github/ISSUE_TEMPLATE/css-issue.yml b/.github/ISSUE_TEMPLATE/css-issue.yml
index 591bf1b46..e42dd65f3 100644
--- a/.github/ISSUE_TEMPLATE/css-issue.yml
+++ b/.github/ISSUE_TEMPLATE/css-issue.yml
@@ -76,6 +76,7 @@ body:
- PostCSS Color Hex Alpha
- PostCSS Color Mix Function
- PostCSS Conditional Values
+ - PostCSS Content Alt Text
- PostCSS Contrast Color Functions
- PostCSS Custom Media Queries
- PostCSS Custom Properties
diff --git a/.github/ISSUE_TEMPLATE/plugin-issue.yml b/.github/ISSUE_TEMPLATE/plugin-issue.yml
index 665861495..554cdfcfe 100644
--- a/.github/ISSUE_TEMPLATE/plugin-issue.yml
+++ b/.github/ISSUE_TEMPLATE/plugin-issue.yml
@@ -73,6 +73,7 @@ body:
- PostCSS Color Hex Alpha
- PostCSS Color Mix Function
- PostCSS Conditional Values
+ - PostCSS Content Alt Text
- PostCSS Contrast Color Functions
- PostCSS Custom Media Queries
- PostCSS Custom Properties
diff --git a/.github/labeler.yml b/.github/labeler.yml
index a3d24f37b..806e033cc 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -119,6 +119,12 @@
- plugins/postcss-conditional-values/**
- experimental/postcss-conditional-values/**
+"plugins/postcss-content-alt-text":
+ - changed-files:
+ - any-glob-to-any-file:
+ - plugins/postcss-content-alt-text/**
+ - experimental/postcss-content-alt-text/**
+
"plugins/postcss-contrast-color-function":
- changed-files:
- any-glob-to-any-file:
diff --git a/package-lock.json b/package-lock.json
index 04ccebf68..fa4b3bf36 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2081,6 +2081,10 @@
"resolved": "plugins/postcss-conditional-values",
"link": true
},
+ "node_modules/@csstools/postcss-content-alt-text": {
+ "resolved": "plugins/postcss-content-alt-text",
+ "link": true
+ },
"node_modules/@csstools/postcss-contrast-color-function": {
"resolved": "plugins/postcss-contrast-color-function",
"link": true
@@ -10344,6 +10348,36 @@
"postcss": "^8.4"
}
},
+ "plugins/postcss-content-alt-text": {
+ "name": "@csstools/postcss-content-alt-text",
+ "version": "0.0.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "dependencies": {
+ "@csstools/css-parser-algorithms": "^2.7.1",
+ "@csstools/css-tokenizer": "^2.4.1",
+ "@csstools/postcss-progressive-custom-properties": "^3.2.0",
+ "@csstools/utilities": "^1.0.0"
+ },
+ "devDependencies": {
+ "@csstools/postcss-tape": "*"
+ },
+ "engines": {
+ "node": "^14 || ^16 || >=18"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4"
+ }
+ },
"plugins/postcss-contrast-color-function": {
"name": "@csstools/postcss-contrast-color-function",
"version": "1.0.5",
diff --git a/plugins/postcss-content-alt-text/.gitignore b/plugins/postcss-content-alt-text/.gitignore
new file mode 100644
index 000000000..e5b28db4a
--- /dev/null
+++ b/plugins/postcss-content-alt-text/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+package-lock.json
+yarn.lock
+*.result.css
+*.result.css.map
+*.result.html
diff --git a/plugins/postcss-content-alt-text/.nvmrc b/plugins/postcss-content-alt-text/.nvmrc
new file mode 100644
index 000000000..6ed5da955
--- /dev/null
+++ b/plugins/postcss-content-alt-text/.nvmrc
@@ -0,0 +1 @@
+v20.2.0
diff --git a/plugins/postcss-content-alt-text/CHANGELOG.md b/plugins/postcss-content-alt-text/CHANGELOG.md
new file mode 100644
index 000000000..a64fbd269
--- /dev/null
+++ b/plugins/postcss-content-alt-text/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changes to PostCSS Content Alt Text
+
+### Unreleased (major)
+
+- Initial version
diff --git a/plugins/postcss-content-alt-text/INSTALL.md b/plugins/postcss-content-alt-text/INSTALL.md
new file mode 100644
index 000000000..335fcbabd
--- /dev/null
+++ b/plugins/postcss-content-alt-text/INSTALL.md
@@ -0,0 +1,235 @@
+# Installing PostCSS Content Alt Text
+
+[PostCSS Content Alt Text] runs in all Node environments, with special instructions for:
+
+- [Node](#node)
+- [PostCSS CLI](#postcss-cli)
+- [PostCSS Load Config](#postcss-load-config)
+- [Webpack](#webpack)
+- [Next.js](#nextjs)
+- [Gulp](#gulp)
+- [Grunt](#grunt)
+
+
+
+## Node
+
+Add [PostCSS Content Alt Text] to your project:
+
+```bash
+npm install postcss @csstools/postcss-content-alt-text --save-dev
+```
+
+Use it as a [PostCSS] plugin:
+
+```js
+// commonjs
+const postcss = require('postcss');
+const postcssContentAltText = require('@csstools/postcss-content-alt-text');
+
+postcss([
+ postcssContentAltText(/* pluginOptions */)
+]).process(YOUR_CSS /*, processOptions */);
+```
+
+```js
+// esm
+import postcss from 'postcss';
+import postcssContentAltText from '@csstools/postcss-content-alt-text';
+
+postcss([
+ postcssContentAltText(/* pluginOptions */)
+]).process(YOUR_CSS /*, processOptions */);
+```
+
+## PostCSS CLI
+
+Add [PostCSS CLI] to your project:
+
+```bash
+npm install postcss-cli @csstools/postcss-content-alt-text --save-dev
+```
+
+Use [PostCSS Content Alt Text] in your `postcss.config.js` configuration file:
+
+```js
+const postcssContentAltText = require('@csstools/postcss-content-alt-text');
+
+module.exports = {
+ plugins: [
+ postcssContentAltText(/* pluginOptions */)
+ ]
+}
+```
+
+## PostCSS Load Config
+
+If your framework/CLI supports [`postcss-load-config`](https://github.com/postcss/postcss-load-config).
+
+```bash
+npm install @csstools/postcss-content-alt-text --save-dev
+```
+
+`package.json`:
+
+```json
+{
+ "postcss": {
+ "plugins": {
+ "@csstools/postcss-content-alt-text": {}
+ }
+ }
+}
+```
+
+`.postcssrc.json`:
+
+```json
+{
+ "plugins": {
+ "@csstools/postcss-content-alt-text": {}
+ }
+}
+```
+
+_See the [README of `postcss-load-config`](https://github.com/postcss/postcss-load-config#usage) for more usage options._
+
+## Webpack
+
+_Webpack version 5_
+
+Add [PostCSS Loader] to your project:
+
+```bash
+npm install postcss-loader @csstools/postcss-content-alt-text --save-dev
+```
+
+Use [PostCSS Content Alt Text] in your Webpack configuration:
+
+```js
+module.exports = {
+ module: {
+ rules: [
+ {
+ test: /\.css$/i,
+ use: [
+ "style-loader",
+ {
+ loader: "css-loader",
+ options: { importLoaders: 1 },
+ },
+ {
+ loader: "postcss-loader",
+ options: {
+ postcssOptions: {
+ plugins: [
+ // Other plugins,
+ [
+ "@csstools/postcss-content-alt-text",
+ {
+ // Options
+ },
+ ],
+ ],
+ },
+ },
+ },
+ ],
+ },
+ ],
+ },
+};
+```
+
+## Next.js
+
+Read the instructions on how to [customize the PostCSS configuration in Next.js](https://nextjs.org/docs/advanced-features/customizing-postcss-config)
+
+```bash
+npm install @csstools/postcss-content-alt-text --save-dev
+```
+
+Use [PostCSS Content Alt Text] in your `postcss.config.json` file:
+
+```json
+{
+ "plugins": [
+ "@csstools/postcss-content-alt-text"
+ ]
+}
+```
+
+```json5
+{
+ "plugins": [
+ [
+ "@csstools/postcss-content-alt-text",
+ {
+ // Optionally add plugin options
+ }
+ ]
+ ]
+}
+```
+
+## Gulp
+
+Add [Gulp PostCSS] to your project:
+
+```bash
+npm install gulp-postcss @csstools/postcss-content-alt-text --save-dev
+```
+
+Use [PostCSS Content Alt Text] in your Gulpfile:
+
+```js
+const postcss = require('gulp-postcss');
+const postcssContentAltText = require('@csstools/postcss-content-alt-text');
+
+gulp.task('css', function () {
+ var plugins = [
+ postcssContentAltText(/* pluginOptions */)
+ ];
+
+ return gulp.src('./src/*.css')
+ .pipe(postcss(plugins))
+ .pipe(gulp.dest('.'));
+});
+```
+
+## Grunt
+
+Add [Grunt PostCSS] to your project:
+
+```bash
+npm install grunt-postcss @csstools/postcss-content-alt-text --save-dev
+```
+
+Use [PostCSS Content Alt Text] in your Gruntfile:
+
+```js
+const postcssContentAltText = require('@csstools/postcss-content-alt-text');
+
+grunt.loadNpmTasks('grunt-postcss');
+
+grunt.initConfig({
+ postcss: {
+ options: {
+ processors: [
+ postcssContentAltText(/* pluginOptions */)
+ ]
+ },
+ dist: {
+ src: '*.css'
+ }
+ }
+});
+```
+
+[Gulp PostCSS]: https://github.com/postcss/gulp-postcss
+[Grunt PostCSS]: https://github.com/nDmitry/grunt-postcss
+[PostCSS]: https://github.com/postcss/postcss
+[PostCSS CLI]: https://github.com/postcss/postcss-cli
+[PostCSS Loader]: https://github.com/postcss/postcss-loader
+[PostCSS Content Alt Text]: https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-content-alt-text
+[Next.js]: https://nextjs.org
diff --git a/plugins/postcss-content-alt-text/LICENSE.md b/plugins/postcss-content-alt-text/LICENSE.md
new file mode 100644
index 000000000..e8ae93b9f
--- /dev/null
+++ b/plugins/postcss-content-alt-text/LICENSE.md
@@ -0,0 +1,18 @@
+MIT No Attribution (MIT-0)
+
+Copyright © CSSTools Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the “Software”), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugins/postcss-content-alt-text/README.md b/plugins/postcss-content-alt-text/README.md
new file mode 100644
index 000000000..ca9a6f1b7
--- /dev/null
+++ b/plugins/postcss-content-alt-text/README.md
@@ -0,0 +1,109 @@
+# PostCSS Content Alt Text [
][PostCSS]
+
+[
][npm-url] [
][cli-url] [
][discord]
[
][css-url] [
][css-url]
+
+```bash
+npm install @csstools/postcss-content-alt-text --save-dev
+```
+
+[PostCSS Content Alt Text] generates fallback values for `content` with alt text following the [CSS Generated Content Module].
+
+```pcss
+.foo {
+ content: url(tree.jpg) / "A beautiful tree in a dark forest";
+}
+
+/* becomes */
+
+.foo {
+ content: url(tree.jpg) "A beautiful tree in a dark forest";
+ content: url(tree.jpg) / "A beautiful tree in a dark forest";
+}
+```
+
+## Usage
+
+Add [PostCSS Content Alt Text] to your project:
+
+```bash
+npm install postcss @csstools/postcss-content-alt-text --save-dev
+```
+
+Use it as a [PostCSS] plugin:
+
+```js
+const postcss = require('postcss');
+const postcssContentAltText = require('@csstools/postcss-content-alt-text');
+
+postcss([
+ postcssContentAltText(/* pluginOptions */)
+]).process(YOUR_CSS /*, processOptions */);
+```
+
+[PostCSS Content Alt Text] runs in all Node environments, with special
+instructions for:
+
+- [Node](INSTALL.md#node)
+- [PostCSS CLI](INSTALL.md#postcss-cli)
+- [PostCSS Load Config](INSTALL.md#postcss-load-config)
+- [Webpack](INSTALL.md#webpack)
+- [Next.js](INSTALL.md#nextjs)
+- [Gulp](INSTALL.md#gulp)
+- [Grunt](INSTALL.md#grunt)
+
+## Options
+
+### preserve
+
+The `preserve` option determines whether the original notation
+is preserved. By default, it is preserved.
+
+```js
+postcssContentAltText({ preserve: false })
+```
+
+```pcss
+.foo {
+ content: url(tree.jpg) / "A beautiful tree in a dark forest";
+}
+
+/* becomes */
+
+.foo {
+ content: url(tree.jpg) "A beautiful tree in a dark forest";
+}
+```
+
+### stripAltText
+
+The `stripAltText` option determines whether the alt text is removed from the value.
+By default, it is not removed.
+Instead it is added to the `content` value itself to ensure content is accessible.
+
+Only set this to `true` if you are sure the alt text is not needed.
+
+```js
+postcssContentAltText({ stripAltText: true })
+```
+
+```pcss
+.foo {
+ content: url(tree.jpg) / "A beautiful tree in a dark forest";
+}
+
+/* becomes */
+
+.foo {
+ content: url(tree.jpg) ;
+ content: url(tree.jpg) / "A beautiful tree in a dark forest";
+}
+```
+
+[cli-url]: https://github.com/csstools/postcss-plugins/actions/workflows/test.yml?query=workflow/test
+[css-url]: https://cssdb.org/#content-alt-text
+[discord]: https://discord.gg/bUadyRwkJS
+[npm-url]: https://www.npmjs.com/package/@csstools/postcss-content-alt-text
+
+[PostCSS]: https://github.com/postcss/postcss
+[PostCSS Content Alt Text]: https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-content-alt-text
+[CSS Generated Content Module]: https://drafts.csswg.org/css-content/#content-property
diff --git a/plugins/postcss-content-alt-text/api-extractor.json b/plugins/postcss-content-alt-text/api-extractor.json
new file mode 100644
index 000000000..42058be51
--- /dev/null
+++ b/plugins/postcss-content-alt-text/api-extractor.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
+ "extends": "../../api-extractor.json"
+}
diff --git a/plugins/postcss-content-alt-text/dist/index.cjs b/plugins/postcss-content-alt-text/dist/index.cjs
new file mode 100644
index 000000000..f016340ff
--- /dev/null
+++ b/plugins/postcss-content-alt-text/dist/index.cjs
@@ -0,0 +1 @@
+"use strict";var e=require("@csstools/css-parser-algorithms"),s=require("@csstools/css-tokenizer"),t=require("@csstools/postcss-progressive-custom-properties"),o=require("@csstools/utilities");const r={test:e=>e.includes("content:")&&e.includes("/")},basePlugin=t=>({postcssPlugin:"postcss-content-alt-text",Declaration(n){if("content"!==n.prop||!n.value.includes("/"))return;if(o.hasFallback(n))return;if(o.hasSupportsAtRuleAncestor(n,r))return;const i=e.parseListOfComponentValues(s.tokenize({css:n.value}));let c=0;for(let o=i.length-1;o>=0;o--){const r=i[o];if(!e.isTokenNode(r))continue;const n=r.value;s.isTokenDelim(n)&&("/"===n[4].value&&(c++,!0===t?.stripAltText?i.splice(o,i.length):i.splice(o,1)))}if(1!==c)return;const l=e.stringify([i]);l!==n.value&&(n.cloneBefore({value:l}),!1===t?.preserve&&n.remove())}});basePlugin.postcss=!0;const creator=e=>{const s=Object.assign({enableProgressiveCustomProperties:!0,preserve:!0,stripAltText:!1},e);return s.enableProgressiveCustomProperties&&s.preserve?{postcssPlugin:"postcss-content-alt-text",plugins:[t(),basePlugin(s)]}:basePlugin(s)};creator.postcss=!0,module.exports=creator;
diff --git a/plugins/postcss-content-alt-text/dist/index.d.ts b/plugins/postcss-content-alt-text/dist/index.d.ts
new file mode 100644
index 000000000..2ebb7a3be
--- /dev/null
+++ b/plugins/postcss-content-alt-text/dist/index.d.ts
@@ -0,0 +1,24 @@
+import type { PluginCreator } from 'postcss';
+
+/** postcss-content-alt-text plugin options */
+export declare type basePluginOptions = {
+ /** Preserve the original notation. default: true */
+ preserve: boolean;
+ /** Strip alt text */
+ stripAltText: boolean;
+};
+
+declare const creator: PluginCreator;
+export default creator;
+
+/** postcss-content-alt-text plugin options */
+export declare type pluginOptions = {
+ /** Preserve the original notation. default: true */
+ preserve?: boolean;
+ /** Strip alt text */
+ stripAltText?: boolean;
+ /** Enable "@csstools/postcss-progressive-custom-properties". default: true */
+ enableProgressiveCustomProperties?: boolean;
+};
+
+export { }
diff --git a/plugins/postcss-content-alt-text/dist/index.mjs b/plugins/postcss-content-alt-text/dist/index.mjs
new file mode 100644
index 000000000..0b488d05c
--- /dev/null
+++ b/plugins/postcss-content-alt-text/dist/index.mjs
@@ -0,0 +1 @@
+import{parseListOfComponentValues as s,isTokenNode as e,stringify as t}from"@csstools/css-parser-algorithms";import{tokenize as o,isTokenDelim as r}from"@csstools/css-tokenizer";import n from"@csstools/postcss-progressive-custom-properties";import{hasFallback as c,hasSupportsAtRuleAncestor as i}from"@csstools/utilities";const l={test:s=>s.includes("content:")&&s.includes("/")},basePlugin=n=>({postcssPlugin:"postcss-content-alt-text",Declaration(p){if("content"!==p.prop||!p.value.includes("/"))return;if(c(p))return;if(i(p,l))return;const u=s(o({css:p.value}));let a=0;for(let s=u.length-1;s>=0;s--){const t=u[s];if(!e(t))continue;const o=t.value;r(o)&&("/"===o[4].value&&(a++,!0===n?.stripAltText?u.splice(s,u.length):u.splice(s,1)))}if(1!==a)return;const m=t([u]);m!==p.value&&(p.cloneBefore({value:m}),!1===n?.preserve&&p.remove())}});basePlugin.postcss=!0;const creator=s=>{const e=Object.assign({enableProgressiveCustomProperties:!0,preserve:!0,stripAltText:!1},s);return e.enableProgressiveCustomProperties&&e.preserve?{postcssPlugin:"postcss-content-alt-text",plugins:[n(),basePlugin(e)]}:basePlugin(e)};creator.postcss=!0;export{creator as default};
diff --git a/plugins/postcss-content-alt-text/docs/README.md b/plugins/postcss-content-alt-text/docs/README.md
new file mode 100644
index 000000000..dfecd731d
--- /dev/null
+++ b/plugins/postcss-content-alt-text/docs/README.md
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+[] generates fallback values for `content` with alt text following the [CSS Generated Content Module].
+
+```pcss
+
+
+/* becomes */
+
+
+```
+
+
+
+
+
+## Options
+
+### preserve
+
+The `preserve` option determines whether the original notation
+is preserved. By default, it is preserved.
+
+```js
+({ preserve: false })
+```
+
+```pcss
+
+
+/* becomes */
+
+
+```
+
+### stripAltText
+
+The `stripAltText` option determines whether the alt text is removed from the value.
+By default, it is not removed.
+Instead it is added to the `content` value itself to ensure content is accessible.
+
+Only set this to `true` if you are sure the alt text is not needed.
+
+```js
+({ stripAltText: true })
+```
+
+```pcss
+
+
+/* becomes */
+
+
+```
+
+
+[CSS Generated Content Module]:
diff --git a/plugins/postcss-content-alt-text/package.json b/plugins/postcss-content-alt-text/package.json
new file mode 100644
index 000000000..ac937239c
--- /dev/null
+++ b/plugins/postcss-content-alt-text/package.json
@@ -0,0 +1,96 @@
+{
+ "name": "@csstools/postcss-content-alt-text",
+ "description": "Generate fallback values for content with alt text",
+ "version": "0.0.0",
+ "contributors": [
+ {
+ "name": "Antonio Laguna",
+ "email": "antonio@laguna.es",
+ "url": "https://antonio.laguna.es"
+ },
+ {
+ "name": "Romain Menke",
+ "email": "romainmenke@gmail.com"
+ }
+ ],
+ "license": "MIT-0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "engines": {
+ "node": "^14 || ^16 || >=18"
+ },
+ "type": "module",
+ "main": "dist/index.cjs",
+ "module": "dist/index.mjs",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "default": "./dist/index.cjs"
+ }
+ }
+ },
+ "files": [
+ "CHANGELOG.md",
+ "LICENSE.md",
+ "README.md",
+ "dist"
+ ],
+ "dependencies": {
+ "@csstools/css-parser-algorithms": "^2.7.1",
+ "@csstools/css-tokenizer": "^2.4.1",
+ "@csstools/postcss-progressive-custom-properties": "^3.2.0",
+ "@csstools/utilities": "^1.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4"
+ },
+ "devDependencies": {
+ "@csstools/postcss-tape": "*"
+ },
+ "scripts": {
+ "build": "rollup -c ../../rollup/default.mjs",
+ "docs": "node ../../.github/bin/generate-docs/install.mjs && node ../../.github/bin/generate-docs/readme.mjs",
+ "lint": "node ../../.github/bin/format-package-json.mjs",
+ "prepublishOnly": "npm run build && npm run test",
+ "test": "node --test",
+ "test:rewrite-expects": "REWRITE_EXPECTS=true node --test"
+ },
+ "homepage": "https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-content-alt-text#readme",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/csstools/postcss-plugins.git",
+ "directory": "plugins/postcss-content-alt-text"
+ },
+ "bugs": "https://github.com/csstools/postcss-plugins/issues",
+ "keywords": [
+ "accessibility",
+ "alt",
+ "content",
+ "css",
+ "csswg",
+ "fallback",
+ "postcss-plugin",
+ "w3c"
+ ],
+ "csstools": {
+ "cssdbId": "content-alt-text",
+ "exportName": "postcssContentAltText",
+ "humanReadableName": "PostCSS Content Alt Text",
+ "specUrl": "https://drafts.csswg.org/css-content/#content-property"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/plugins/postcss-content-alt-text/src/index.ts b/plugins/postcss-content-alt-text/src/index.ts
new file mode 100644
index 000000000..ad5fcf778
--- /dev/null
+++ b/plugins/postcss-content-alt-text/src/index.ts
@@ -0,0 +1,122 @@
+import { isTokenNode, parseListOfComponentValues, stringify } from '@csstools/css-parser-algorithms';
+import { isTokenDelim, tokenize } from '@csstools/css-tokenizer';
+import postcssProgressiveCustomProperties from '@csstools/postcss-progressive-custom-properties';
+import { hasFallback, hasSupportsAtRuleAncestor } from '@csstools/utilities';
+import type { PluginCreator } from 'postcss';
+
+/** postcss-content-alt-text plugin options */
+export type basePluginOptions = {
+ /** Preserve the original notation. default: true */
+ preserve: boolean,
+ /** Strip alt text */
+ stripAltText: boolean,
+};
+
+const predicate = {
+ test: (str: string): boolean => {
+ return str.includes('content:') && str.includes('/');
+ }
+}
+
+const basePlugin: PluginCreator = (opts?: basePluginOptions) => {
+ return {
+ postcssPlugin: 'postcss-content-alt-text',
+ Declaration(decl): void {
+ if (decl.prop !== 'content' || !decl.value.includes('/')) {
+ return;
+ }
+
+ if (hasFallback(decl)) {
+ return;
+ }
+
+ if (hasSupportsAtRuleAncestor(decl, predicate)) {
+ return;
+ }
+
+ const componentValues = parseListOfComponentValues(
+ tokenize({ css: decl.value })
+ );
+
+ let slashCounter = 0;
+
+ for (let i = (componentValues.length - 1); i >= 0; i--) {
+ const componentValue = componentValues[i];
+ if (!isTokenNode(componentValue)) {
+ continue;
+ }
+
+ const token = componentValue.value;
+ if (!isTokenDelim(token)) {
+ continue;
+ }
+
+ if (token[4].value !== '/') {
+ continue;
+ }
+
+ slashCounter++;
+
+ if (opts?.stripAltText === true) {
+ componentValues.splice(i, componentValues.length);
+ } else {
+ componentValues.splice(i, 1);
+ }
+ }
+
+ if (slashCounter !== 1) {
+ // Either too few or too many slashes
+ return;
+ }
+
+ const modified = stringify([componentValues]);
+
+ if (modified === decl.value) {
+ return;
+ }
+
+ decl.cloneBefore({ value: modified });
+
+ if (opts?.preserve === false) {
+ decl.remove();
+ }
+ },
+ };
+};
+
+basePlugin.postcss = true;
+
+
+/** postcss-content-alt-text plugin options */
+export type pluginOptions = {
+ /** Preserve the original notation. default: true */
+ preserve?: boolean,
+ /** Strip alt text */
+ stripAltText?: boolean,
+ /** Enable "@csstools/postcss-progressive-custom-properties". default: true */
+ enableProgressiveCustomProperties?: boolean,
+};
+
+const creator: PluginCreator = (opts?: pluginOptions) => {
+ const options = Object.assign({
+ enableProgressiveCustomProperties: true,
+ preserve: true,
+ stripAltText: false,
+ }, opts);
+
+ if (options.enableProgressiveCustomProperties && options.preserve) {
+ return {
+ postcssPlugin: 'postcss-content-alt-text',
+ plugins: [
+ postcssProgressiveCustomProperties(),
+ basePlugin(options),
+ ],
+ };
+ }
+
+ return basePlugin(options);
+};
+
+creator.postcss = true;
+
+export default creator;
diff --git a/plugins/postcss-content-alt-text/test/_import.mjs b/plugins/postcss-content-alt-text/test/_import.mjs
new file mode 100644
index 000000000..04228bc80
--- /dev/null
+++ b/plugins/postcss-content-alt-text/test/_import.mjs
@@ -0,0 +1,10 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import plugin from '@csstools/postcss-content-alt-text';
+
+test('import', () => {
+ plugin();
+ assert.ok(plugin.postcss, 'should have "postcss flag"');
+ assert.equal(typeof plugin, 'function', 'should return a function');
+});
+
diff --git a/plugins/postcss-content-alt-text/test/_require.cjs b/plugins/postcss-content-alt-text/test/_require.cjs
new file mode 100644
index 000000000..027c0494a
--- /dev/null
+++ b/plugins/postcss-content-alt-text/test/_require.cjs
@@ -0,0 +1,9 @@
+const assert = require('node:assert/strict');
+const test = require('node:test');
+const plugin = require('@csstools/postcss-content-alt-text');
+
+test('require', () => {
+ plugin();
+ assert.ok(plugin.postcss, 'should have "postcss flag"');
+ assert.equal(typeof plugin, 'function', 'should return a function');
+});
diff --git a/plugins/postcss-content-alt-text/test/_tape.mjs b/plugins/postcss-content-alt-text/test/_tape.mjs
new file mode 100644
index 000000000..35e4cce30
--- /dev/null
+++ b/plugins/postcss-content-alt-text/test/_tape.mjs
@@ -0,0 +1,35 @@
+import { postcssTape } from '@csstools/postcss-tape';
+import plugin from '@csstools/postcss-content-alt-text';
+
+postcssTape(plugin)({
+ basic: {
+ message: 'supports basic usage',
+ },
+ 'basic:preserve-false': {
+ message: 'supports basic usage with { preserve: false }',
+ options: {
+ preserve: false,
+ },
+ },
+ 'basic:strip-alt-text': {
+ message: 'supports basic usage with { stripAltText: true }',
+ options: {
+ stripAltText: true,
+ },
+ },
+ 'examples/example': {
+ message: 'minimal example',
+ },
+ 'examples/example:preserve-false': {
+ message: 'minimal example',
+ options: {
+ preserve: false,
+ },
+ },
+ 'examples/example:strip-alt-text': {
+ message: 'minimal example',
+ options: {
+ stripAltText: true,
+ },
+ },
+});
diff --git a/plugins/postcss-content-alt-text/test/basic.css b/plugins/postcss-content-alt-text/test/basic.css
new file mode 100644
index 000000000..6872ef12d
--- /dev/null
+++ b/plugins/postcss-content-alt-text/test/basic.css
@@ -0,0 +1,43 @@
+.foo {
+ content: "1" / "0";
+}
+
+.foo {
+ content: var(--b) / var(--a);
+}
+
+.foo {
+ content: "2" "0" / "0";
+}
+
+.foo {
+ content: "3" / "0" "0";
+}
+
+.ignore {
+ content: "4" "0";
+}
+
+.ignore {
+ content: "5" / "0" / "0";
+}
+
+.ignore {
+ content: "6" var(--foo, "0" / "0");
+}
+
+.ignore {
+ content: "7";
+ content: "7" / "0";
+}
+
+.ignore {
+ content: "8" "0";
+ content: "8" / "0";
+}
+
+@supports (content: "b" / "c") {
+ .ignore {
+ content: "9" / "0";
+ }
+}
diff --git a/plugins/postcss-content-alt-text/test/basic.expect.css b/plugins/postcss-content-alt-text/test/basic.expect.css
new file mode 100644
index 000000000..b4cbd27b7
--- /dev/null
+++ b/plugins/postcss-content-alt-text/test/basic.expect.css
@@ -0,0 +1,52 @@
+.foo {
+ content: "1" "0";
+ content: "1" / "0";
+}
+
+.foo {
+ content: var(--b) var(--a);
+}
+
+@supports (content: "a" / "a") {
+.foo {
+ content: var(--b) / var(--a);
+}
+}
+
+.foo {
+ content: "2" "0" "0";
+ content: "2" "0" / "0";
+}
+
+.foo {
+ content: "3" "0" "0";
+ content: "3" / "0" "0";
+}
+
+.ignore {
+ content: "4" "0";
+}
+
+.ignore {
+ content: "5" / "0" / "0";
+}
+
+.ignore {
+ content: "6" var(--foo, "0" / "0");
+}
+
+.ignore {
+ content: "7";
+ content: "7" / "0";
+}
+
+.ignore {
+ content: "8" "0";
+ content: "8" / "0";
+}
+
+@supports (content: "b" / "c") {
+ .ignore {
+ content: "9" / "0";
+ }
+}
diff --git a/plugins/postcss-content-alt-text/test/basic.preserve-false.expect.css b/plugins/postcss-content-alt-text/test/basic.preserve-false.expect.css
new file mode 100644
index 000000000..521a80f3d
--- /dev/null
+++ b/plugins/postcss-content-alt-text/test/basic.preserve-false.expect.css
@@ -0,0 +1,43 @@
+.foo {
+ content: "1" "0";
+}
+
+.foo {
+ content: var(--b) var(--a);
+}
+
+.foo {
+ content: "2" "0" "0";
+}
+
+.foo {
+ content: "3" "0" "0";
+}
+
+.ignore {
+ content: "4" "0";
+}
+
+.ignore {
+ content: "5" / "0" / "0";
+}
+
+.ignore {
+ content: "6" var(--foo, "0" / "0");
+}
+
+.ignore {
+ content: "7";
+ content: "7" / "0";
+}
+
+.ignore {
+ content: "8" "0";
+ content: "8" / "0";
+}
+
+@supports (content: "b" / "c") {
+ .ignore {
+ content: "9" / "0";
+ }
+}
diff --git a/plugins/postcss-content-alt-text/test/basic.strip-alt-text.expect.css b/plugins/postcss-content-alt-text/test/basic.strip-alt-text.expect.css
new file mode 100644
index 000000000..956530d79
--- /dev/null
+++ b/plugins/postcss-content-alt-text/test/basic.strip-alt-text.expect.css
@@ -0,0 +1,52 @@
+.foo {
+ content: "1" ;
+ content: "1" / "0";
+}
+
+.foo {
+ content: var(--b) ;
+}
+
+@supports (content: "a" / "a") {
+.foo {
+ content: var(--b) / var(--a);
+}
+}
+
+.foo {
+ content: "2" "0" ;
+ content: "2" "0" / "0";
+}
+
+.foo {
+ content: "3" ;
+ content: "3" / "0" "0";
+}
+
+.ignore {
+ content: "4" "0";
+}
+
+.ignore {
+ content: "5" / "0" / "0";
+}
+
+.ignore {
+ content: "6" var(--foo, "0" / "0");
+}
+
+.ignore {
+ content: "7";
+ content: "7" / "0";
+}
+
+.ignore {
+ content: "8" "0";
+ content: "8" / "0";
+}
+
+@supports (content: "b" / "c") {
+ .ignore {
+ content: "9" / "0";
+ }
+}
diff --git a/plugins/postcss-content-alt-text/test/examples/example.css b/plugins/postcss-content-alt-text/test/examples/example.css
new file mode 100644
index 000000000..0bcf7c152
--- /dev/null
+++ b/plugins/postcss-content-alt-text/test/examples/example.css
@@ -0,0 +1,3 @@
+.foo {
+ content: url(tree.jpg) / "A beautiful tree in a dark forest";
+}
diff --git a/plugins/postcss-content-alt-text/test/examples/example.expect.css b/plugins/postcss-content-alt-text/test/examples/example.expect.css
new file mode 100644
index 000000000..d2f53dbb2
--- /dev/null
+++ b/plugins/postcss-content-alt-text/test/examples/example.expect.css
@@ -0,0 +1,4 @@
+.foo {
+ content: url(tree.jpg) "A beautiful tree in a dark forest";
+ content: url(tree.jpg) / "A beautiful tree in a dark forest";
+}
diff --git a/plugins/postcss-content-alt-text/test/examples/example.preserve-false.expect.css b/plugins/postcss-content-alt-text/test/examples/example.preserve-false.expect.css
new file mode 100644
index 000000000..677dc3d6d
--- /dev/null
+++ b/plugins/postcss-content-alt-text/test/examples/example.preserve-false.expect.css
@@ -0,0 +1,3 @@
+.foo {
+ content: url(tree.jpg) "A beautiful tree in a dark forest";
+}
diff --git a/plugins/postcss-content-alt-text/test/examples/example.strip-alt-text.expect.css b/plugins/postcss-content-alt-text/test/examples/example.strip-alt-text.expect.css
new file mode 100644
index 000000000..99ab75472
--- /dev/null
+++ b/plugins/postcss-content-alt-text/test/examples/example.strip-alt-text.expect.css
@@ -0,0 +1,4 @@
+.foo {
+ content: url(tree.jpg) ;
+ content: url(tree.jpg) / "A beautiful tree in a dark forest";
+}
diff --git a/plugins/postcss-content-alt-text/tsconfig.json b/plugins/postcss-content-alt-text/tsconfig.json
new file mode 100644
index 000000000..500af6d26
--- /dev/null
+++ b/plugins/postcss-content-alt-text/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "declarationDir": ".",
+ "strict": true
+ },
+ "include": ["./src/**/*"],
+ "exclude": ["dist"]
+}