├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── report-a-bug.md │ └── request-a-feature.md ├── dependabot.yml └── workflows │ ├── CI.yml │ ├── CodeQL.yml │ ├── Sonar.yml │ └── nuget.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── SrcSet.Core.Tests ├── FileHelperTests.cs ├── ImageExtensionTests.cs ├── SizeExtensionsTests.cs ├── SrcSet.Core.Tests.csproj └── SrcSetManagerTests.cs ├── SrcSet.Core ├── FileHelpers.cs ├── ImageExtensions.cs ├── SizeExtensions.cs ├── SrcSet.Core.csproj ├── SrcSetManager.cs └── SupportedFormats.cs ├── SrcSet.Statiq.Tests ├── CreateResponsiveImagesTests.cs ├── NormalizedPathExtensionTests.cs ├── ResponsiveImagesTests.cs └── SrcSet.Statiq.Tests.csproj ├── SrcSet.Statiq ├── CreateResponsiveImages.cs ├── NormalizedPathExtensions.cs ├── ResponsiveImages.cs └── SrcSet.Statiq.csproj ├── SrcSet.Tests ├── ArgumentsTests.cs ├── ProgramTests.cs └── SrcSet.Tests.csproj ├── SrcSet.sln ├── SrcSet ├── App.cs ├── Arguments.cs ├── Command.cs ├── Factory.cs ├── Program.cs ├── Settings.cs └── SrcSet.csproj ├── test-0001.png └── test.png /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | trim_trailing_whitespace = true 7 | insert_final_newline = false 8 | max_line_length = off 9 | 10 | [{*.yml,*.yaml}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.cs] 15 | csharp_prefer_braces = true 16 | csharp_style_expression_bodied_methods = true 17 | csharp_style_expression_bodied_constructors = true 18 | csharp_style_expression_bodied_operators = true 19 | csharp_style_expression_bodied_properties = true 20 | csharp_style_expression_bodied_indexers = true 21 | csharp_style_expression_bodied_accessors = true 22 | csharp_style_expression_bodied_lambdas = true 23 | csharp_style_expression_bodied_local_functions = true 24 | csharp_style_pattern_local_over_anonymous_function = false 25 | csharp_style_namespace_declarations = file_scoped:warning 26 | dotnet_diagnostic.S101.severity = suggestion 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/report-a-bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Report a Bug 3 | about: Describe a problem 4 | 5 | --- 6 | 7 | **Steps to reproduce** 8 | 1. login as X 9 | 1. click X 10 | 1. etc. 11 | 12 | **Expected behavior** 13 | - this should happen 14 | - and this 15 | - etc. 16 | 17 | **Observed behavior** 18 | - this happened instead 19 | - field X got set wrong 20 | - etc. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/request-a-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Request a Feature 3 | about: Suggest new functionality for the app 4 | 5 | --- 6 | 7 | As a ___type_of_user___, I want to ___accomplish_this_goal___, so that ___reason_why_this_would_help___. 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "nuget" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [ push, pull_request ] 3 | 4 | jobs: 5 | Build: 6 | runs-on: ubuntu-latest 7 | env: 8 | TZ: America/New_York 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Setup .NET 14 | uses: actions/setup-dotnet@v4 15 | with: 16 | dotnet-version: 9.0.x 17 | 18 | - name: Install dependencies 19 | run: dotnet restore 20 | 21 | - name: Build 22 | run: dotnet build --no-restore 23 | 24 | - name: Test 25 | run: dotnet test --no-build --verbosity normal -------------------------------------------------------------------------------- /.github/workflows/CodeQL.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '45 7 * * *' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'csharp' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | - name: Setup .NET 54 | uses: actions/setup-dotnet@v4 55 | with: 56 | dotnet-version: 9.0.x 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v3 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 https://git.io/JvXDl 65 | 66 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 67 | # and modify them (or add more) to build your code if your project 68 | # uses a compiled language 69 | 70 | #- run: | 71 | # make bootstrap 72 | # make release 73 | 74 | - name: Perform CodeQL Analysis 75 | uses: github/codeql-action/analyze@v3 76 | -------------------------------------------------------------------------------- /.github/workflows/Sonar.yml: -------------------------------------------------------------------------------- 1 | name: Sonar 2 | on: push 3 | 4 | jobs: 5 | Code-Quality: 6 | runs-on: ubuntu-latest 7 | if: github.actor != 'dependabot[bot]' 8 | 9 | steps: 10 | - name: Debug 11 | run: echo ${{github.actor}} 12 | 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Setup .NET 19 | uses: actions/setup-dotnet@v4 20 | with: 21 | dotnet-version: 9.0.x 22 | 23 | - name: Install Java 24 | uses: actions/setup-java@v4 25 | with: 26 | distribution: microsoft 27 | java-version: 21 28 | 29 | - name: Install Sonar Scanner 30 | run: dotnet tool install --global dotnet-sonarscanner 31 | 32 | - name: Install dependencies 33 | run: dotnet restore 34 | 35 | - name: Start Sonar Analysis 36 | run: dotnet-sonarscanner begin /d:sonar.host.url="https://sonarcloud.io" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /o:"ecoapm" /k:"ecoAPM_SrcSet" /d:sonar.cs.vstest.reportsPaths="*.Tests/TestResults/results.trx" /d:sonar.cs.opencover.reportsPaths="*.Tests/TestResults/**/coverage.opencover.xml" 37 | 38 | - name: Build 39 | run: dotnet build --no-restore 40 | env: 41 | SONAR_DOTNET_ENABLE_CONCURRENT_EXECUTION: true 42 | 43 | - name: Test 44 | run: dotnet test --no-build --logger "trx;LogFileName=results.trx" --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover 45 | 46 | - name: Finish Sonar Analysis 47 | run: dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}" 48 | env: 49 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} -------------------------------------------------------------------------------- /.github/workflows/nuget.yml: -------------------------------------------------------------------------------- 1 | name: NuGet 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | jobs: 8 | Publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | ref: ${{ github.ref }} 14 | 15 | - uses: actions/setup-dotnet@v4 16 | with: 17 | dotnet-version: 9.0.x 18 | 19 | - name: Run tests 20 | run: dotnet test 21 | 22 | - name: Package 23 | run: dotnet pack -c Release -p:ContinuousIntegrationBuild=true 24 | 25 | - name: Publish Core 26 | run: dotnet nuget push SrcSet.Core/bin/Release/SrcSet.Core.$(echo ${{ github.ref }} | sed 's/refs\/tags\///').nupkg -k ${{ secrets.NUGET_TOKEN }} -s https://api.nuget.org/v3/index.json 27 | 28 | - name: Publish App 29 | run: dotnet nuget push SrcSet/bin/Release/SrcSet.$(echo ${{ github.ref }} | sed 's/refs\/tags\///').nupkg -k ${{ secrets.NUGET_TOKEN }} -s https://api.nuget.org/v3/index.json 30 | 31 | - name: Publish Statiq 32 | run: dotnet nuget push SrcSet.Statiq/bin/Release/SrcSet.Statiq.$(echo ${{ github.ref }} | sed 's/refs\/tags\///').nupkg -k ${{ secrets.NUGET_TOKEN }} -s https://api.nuget.org/v3/index.json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | obj 3 | Properties 4 | *.user 5 | .vs 6 | *.lock.json 7 | .idea 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | Steve@ecoAPM.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # ecoAPM Contribution Guidelines 2 | 3 | First of all, thank you for your interest in contributing! 4 | 5 | This document represents a general set of guidelines to help make the process of community contributions as smooth as possible for all parties involved. 6 | 7 | #### Please read the [Code of Conduct](CODE_OF_CONDUCT.md) prior to participating 8 | - This is the standard "Contributor Covenant" used throughout all ecoAPM codebases, and widely across the OSS landscape 9 | - Building a strong, professional, caring, and empathetic community is paramount in our goal as an OSS company 10 | 11 | #### Discussions about changes should happen in an issue before creating a pull request 12 | - While a change may make sense for a specific use case, it may not match the larger goals of the project as initially formulated by the original contributor 13 | - Prior discussion can help give direction to how a feature or bug fix could best be implemented to meet everyone's needs 14 | 15 | #### Follow the standard issue template formats for reporting bugs and requesting new features 16 | - These make reading, understanding, and triaging issues much easier 17 | 18 | #### Commit quality code with detailed documentation to help maximize PR review effectiveness 19 | - All new or modified functionality should have unit tests covering the logic involved 20 | - All PR checks (e.g. automated tests, code quality analysis, etc.) should be passing before a PR is reviewed 21 | - Commit messages should be English (Canadian/UK/US are all acceptable) in the present tense using an imperative form (see existing commits for examples) 22 | - Please do not reference GitHub issue numbers or PR numbers in git commit messages 23 | 24 | #### Multiple smaller, atomic PRs are preferable to single larger monolithic PRs 25 | - This may take longer to get the full changeset merged, but will provide for a much smoother feedback process 26 | - Please reference any related issue numbers in the body of all PR descriptions so that GitHub links them together 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 ecoAPM LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SrcSet 2 | 3 | Tools to create sets of responsive images for the web 4 | 5 | [![SrcSet](https://img.shields.io/nuget/v/SrcSet?logo=nuget&label=SrcSet)](https://www.nuget.org/packages/SrcSet/) 6 | [![SrcSet.Statiq](https://img.shields.io/nuget/v/SrcSet.Statiq?logo=nuget&label=SrcSet.Statiq)](https://www.nuget.org/packages/SrcSet.Statiq/) 7 | [![SrcSet.Core](https://img.shields.io/nuget/v/SrcSet.Core?logo=nuget&label=SrcSet.Core)](https://www.nuget.org/packages/SrcSet.Core/) 8 | 9 | [![Build Status](https://github.com/ecoAPM/SrcSet/workflows/CI/badge.svg)](https://github.com/ecoAPM/SrcSet/actions) 10 | [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=ecoAPM_SrcSet&metric=coverage)](https://sonarcloud.io/dashboard?id=ecoAPM_SrcSet) 11 | 12 | [![Maintainability](https://sonarcloud.io/api/project_badges/measure?project=ecoAPM_SrcSet&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=ecoAPM_SrcSet) 13 | [![Reliability](https://sonarcloud.io/api/project_badges/measure?project=ecoAPM_SrcSet&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=ecoAPM_SrcSet) 14 | [![Security](https://sonarcloud.io/api/project_badges/measure?project=ecoAPM_SrcSet&metric=security_rating)](https://sonarcloud.io/dashboard?id=ecoAPM_SrcSet) 15 | 16 | ## Tools 17 | 18 | This repository contains 3 projects: 19 | - [SrcSet](#cli): a CLI utility to create sets of responsive images 20 | - [SrcSet.Statiq](#statiq): pipeline and helper for integrating responsive images into a Statiq site 21 | - [SrcSet.Core](#library): a library used by the above, also available for public consumption 22 | 23 | ## CLI 24 | 25 | ### Requirements 26 | 27 | - .NET SDK 28 | 29 | ### Installation 30 | 31 | ```bash 32 | dotnet tool install -g SrcSet 33 | ``` 34 | 35 | ### Usage 36 | 37 | ```bash 38 | srcset {file or directory} [-r] [space delimited set of widths] 39 | ``` 40 | 41 | e.g. 42 | 43 | ```bash 44 | srcset IMG_9687.jpg 500 1000 45 | ``` 46 | 47 | will resize the image to 500 and 1000 pixels wide, with the filenames `IMG_9687-0500.jpg` and `IMG_9687-1000.jpg` 48 | 49 | ```bash 50 | srcset all_images/ 51 | ``` 52 | 53 | will resize all images in the `all_images` directory (recursively if `-r` is included) to each of the default widths 54 | 55 | ## Statiq 56 | 57 | This package contains a Statiq pipeline and HTML helper method to easily integrate responsive image generation into a Statiq site. 58 | 59 | The process to accomplish this has two steps: 60 | 1. create the set of responsive images to use (using the pipeline) 61 | 2. reference the images in the generated HTML (using the HTML helper) 62 | 63 | ### Step 1 64 | 65 | To create a set of responsive images, place the originals (to be resized) alongside your other assets, and then in your bootstrapper code, add: 66 | 67 | ```c# 68 | bootstrapper.AddPipeline("SrcSet", new ResponsiveImages("**/*.jpg")); 69 | ``` 70 | 71 | where the optional parameter `**/*.jpg` is a glob pointing to the image assets to resize. 72 | 73 | A custom set of widths can also be passed as a second parameter: 74 | 75 | ```c# 76 | bootstrapper.AddPipeline("SrcSet", new ResponsiveImages("**/*.jpg", new ushort[] { 100, 200, 300 })); 77 | ``` 78 | 79 | ### Step 2 80 | 81 | In your Razor file, call the HTML helper to create an `` tag with the relevant attributes set: 82 | 83 | ```c# 84 | @Html.Raw(ResponsiveImages.SrcSet("images/original.jpg")) 85 | ``` 86 | 87 | You can also customize the widths, default width, and add any other attributes here: 88 | 89 | ```c# 90 | @Html.Raw(ResponsiveImages.SrcSet("images/original.jpg", new ushort[] { 100, 200, 300 }, 200)) 91 | ``` 92 | 93 | ```c# 94 | @Html.Raw(ResponsiveImages.SrcSet("images/original.jpg", attributes: new Dictionary 95 | { 96 | { "class", "full-width" }, 97 | { "alt", "don't forget about accessibility!" } 98 | } 99 | )) 100 | ``` 101 | 102 | ## Library 103 | 104 | The "Core" library can be used to incorporate SrcSet's functionality into your own app. 105 | 106 | First, create a new `SrcSetManager`: 107 | 108 | ```c# 109 | var manager = new SrcSetManager(); 110 | ``` 111 | 112 | Then invoke it's `SaveSrcSet()` method: 113 | 114 | ```c# 115 | await manager.SaveSrcSet("file.png", SrcSetManager.DefaultSizes); 116 | ``` 117 | 118 | If you need more control than the default constructor and sizes provide, you can pass in a specific logging mechanism (other than the default `Console.WriteLine`) and list of widths: 119 | 120 | ```c# 121 | var manager = new SrcSetManager(Image.LoadAsync, (string s) => logger.LogDebug(s)); 122 | await manager.SaveSrcSet("file.png", new ushort[] { 100, 200, 300 }); 123 | ``` 124 | 125 | ### Default widths 126 | 127 | - 240px 128 | - 320px 129 | - 480px 130 | - 640px 131 | - 800px 132 | - 960px 133 | - 1280px 134 | - 1600px 135 | - 1920px 136 | - 2400px 137 | 138 | ### File types 139 | 140 | `SrcSet` uses [ImageSharp](https://imagesharp.net) under the hood, and therefore should theoretically support all file types that ImageSharp supports by entering the filename as a parameter, however when entering a directory as a parameter, file types are limited to: 141 | 142 | - jpg / jpeg / jfif 143 | - png 144 | - bmp / bm / dip 145 | - gif 146 | - tga / vda / icb / vst 147 | 148 | ## Contributing 149 | 150 | Please be sure to read and follow ecoAPM's [Contribution Guidelines](CONTRIBUTING.md) when submitting issues or pull requests. -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Security updates are generally only applied to the newest release, and backported on an as-needed basis where appropriate. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Unless a vulnerability is deemed critical or exposes PII, please create an issue in this repository using the "Report a Bug" template. 10 | 11 | For critical vulnerabilities, or those that expose PII, please email info@ecoAPM.com so that the issue can be fixed confidentially, prior to public disclosure. 12 | -------------------------------------------------------------------------------- /SrcSet.Core.Tests/FileHelperTests.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | namespace SrcSet.Core.Tests; 4 | 5 | public sealed class FileHelperTests 6 | { 7 | [Fact] 8 | public void CanGetFilenameWithWidth() 9 | { 10 | //arrange 11 | const string filename = "test.png"; 12 | 13 | //act 14 | var newName = FileHelpers.GetFilename(filename, 123); 15 | 16 | //assert 17 | Assert.Equal("test-0123.png", newName); 18 | } 19 | } -------------------------------------------------------------------------------- /SrcSet.Core.Tests/ImageExtensionTests.cs: -------------------------------------------------------------------------------- 1 | using SixLabors.ImageSharp; 2 | using SixLabors.ImageSharp.PixelFormats; 3 | using Xunit; 4 | 5 | namespace SrcSet.Core.Tests; 6 | 7 | public sealed class ImageExtensionTests : IDisposable 8 | { 9 | [Fact] 10 | public async Task CanSaveResizedImage() 11 | { 12 | //arrange 13 | var path = Path.Join(Directory.GetCurrentDirectory(), "test.png"); 14 | var image = new Image(2, 1); 15 | 16 | //act 17 | var newName = await image.Save(path); 18 | 19 | //assert 20 | Assert.Equal("test-0002.png", newName); 21 | } 22 | 23 | [Fact] 24 | public async Task SkipsExistingFiles() 25 | { 26 | //arrange 27 | var path = Path.Join(Directory.GetCurrentDirectory(), "test.png"); 28 | var image = new Image(1, 1); 29 | 30 | //act 31 | var newName = await image.Save(path); 32 | 33 | //assert 34 | Assert.Null(newName); 35 | } 36 | 37 | [Fact] 38 | public void CanResizeImage() 39 | { 40 | //arrange 41 | var image = new Image(1, 1); 42 | 43 | //act 44 | var resized = image.Resize(new Size(2, 3)); 45 | 46 | //assert 47 | Assert.Equal(2, resized.Width); 48 | Assert.Equal(3, resized.Height); 49 | } 50 | 51 | public void Dispose() 52 | { 53 | File.Delete("test-0002.png"); 54 | } 55 | } -------------------------------------------------------------------------------- /SrcSet.Core.Tests/SizeExtensionsTests.cs: -------------------------------------------------------------------------------- 1 | using SixLabors.ImageSharp; 2 | using Xunit; 3 | 4 | namespace SrcSet.Core.Tests; 5 | 6 | public sealed class SizeExtensionsTests 7 | { 8 | [Fact] 9 | public void CanResizeImage() 10 | { 11 | //arrange 12 | var size = new Size(6, 4); 13 | 14 | //act 15 | var newSize = size.Resize(3); 16 | 17 | //assert 18 | Assert.Equal(new Size(3, 2), newSize); 19 | } 20 | 21 | [Fact] 22 | public void CanCalculateLandscapeAspectRatio() 23 | { 24 | //arrange 25 | var size = new Size(3, 2); 26 | 27 | //act 28 | var aspectRatio = size.AspectRatio(); 29 | 30 | //assert 31 | Assert.Equal(1.5, aspectRatio); 32 | } 33 | 34 | [Fact] 35 | public void CanCalculatePortraitAspectRatio() 36 | { 37 | //arrange 38 | var size = new Size(3, 4); 39 | 40 | //act 41 | var aspectRatio = size.AspectRatio(); 42 | 43 | //assert 44 | Assert.Equal(0.75, aspectRatio); 45 | } 46 | } -------------------------------------------------------------------------------- /SrcSet.Core.Tests/SrcSet.Core.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /SrcSet.Core.Tests/SrcSetManagerTests.cs: -------------------------------------------------------------------------------- 1 | using SixLabors.ImageSharp; 2 | using SixLabors.ImageSharp.PixelFormats; 3 | using Xunit; 4 | 5 | namespace SrcSet.Core.Tests; 6 | 7 | public sealed class SrcSetManagerTests : IDisposable 8 | { 9 | [Fact] 10 | public void CanCreateDefaultManager() 11 | { 12 | //act 13 | var manager = new SrcSetManager(); 14 | 15 | //assert 16 | Assert.IsType(manager); 17 | } 18 | 19 | [Fact] 20 | public async Task CanResizeImage() 21 | { 22 | //arrange 23 | Image image = new Image(1, 1); 24 | var manager = new SrcSetManager(_ => Task.FromResult(image), _ => { }); 25 | 26 | //act 27 | await manager.SaveSrcSet("test.png", new ushort[] { 3 }); 28 | 29 | //assert 30 | Assert.True(File.Exists("test-0003.png")); 31 | } 32 | 33 | public void Dispose() 34 | => File.Delete("test-0003.png"); 35 | } -------------------------------------------------------------------------------- /SrcSet.Core/FileHelpers.cs: -------------------------------------------------------------------------------- 1 | namespace SrcSet.Core; 2 | 3 | public static class FileHelpers 4 | { 5 | public static string GetFilename(string filePath, ushort width) 6 | { 7 | var file = Path.GetFileNameWithoutExtension(filePath); 8 | var extension = Path.GetExtension(filePath); 9 | return $"{file}-{width:D4}{extension}"; 10 | } 11 | } -------------------------------------------------------------------------------- /SrcSet.Core/ImageExtensions.cs: -------------------------------------------------------------------------------- 1 | using SixLabors.ImageSharp; 2 | using SixLabors.ImageSharp.Processing; 3 | 4 | namespace SrcSet.Core; 5 | 6 | public static class ImageExtensions 7 | { 8 | public static async Task Save(this Image image, string filePath) 9 | { 10 | var newFileName = FileHelpers.GetFilename(filePath, (ushort)image.Width); 11 | var newPath = NewPath(filePath, newFileName); 12 | if (File.Exists(newPath)) 13 | return null; 14 | 15 | await image.SaveAsync(newPath); 16 | return newFileName; 17 | } 18 | 19 | private static string NewPath(string filePath, string newFileName) 20 | { 21 | var dir = Path.GetDirectoryName(filePath) ?? string.Empty; 22 | var newPath = Path.Combine(dir, newFileName); 23 | return newPath; 24 | } 25 | 26 | public static Image Resize(this Image image, Size newSize) 27 | => image.Clone(i => i.Resize(newSize.Width, newSize.Height, KnownResamplers.Lanczos8)); 28 | } -------------------------------------------------------------------------------- /SrcSet.Core/SizeExtensions.cs: -------------------------------------------------------------------------------- 1 | using SixLabors.ImageSharp; 2 | 3 | namespace SrcSet.Core; 4 | 5 | public static class SizeExtensions 6 | { 7 | public static Size Resize(this Size size, ushort width) 8 | => new(width, (ushort)(width / size.AspectRatio())); 9 | 10 | public static double AspectRatio(this Size size) 11 | => (double)size.Width / size.Height; 12 | } -------------------------------------------------------------------------------- /SrcSet.Core/SrcSet.Core.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | SrcSet.Core 4 | A library to create sets of responsive images for the web 5 | 5.0.0 6 | ecoAPM LLC 7 | ecoAPM LLC 8 | ecoAPM LLC 9 | SrcSet 10 | net9.0 11 | README.md 12 | https://github.com/ecoAPM/SrcSet 13 | MIT 14 | https://github.com/ecoAPM/SrcSet 15 | true 16 | true 17 | true 18 | enable 19 | enable 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /SrcSet.Core/SrcSetManager.cs: -------------------------------------------------------------------------------- 1 | using SixLabors.ImageSharp; 2 | 3 | namespace SrcSet.Core; 4 | 5 | public sealed class SrcSetManager 6 | { 7 | public static readonly ushort[] DefaultSizes 8 | = { 240, 320, 480, 640, 800, 960, 1280, 1600, 1920, 2400 }; 9 | 10 | public static readonly IReadOnlyCollection ValidExtensions 11 | = SupportedFormats.Default 12 | .SelectMany(i => i.FileExtensions) 13 | .Select(e => $".{e.ToLower()}") 14 | .ToArray(); 15 | 16 | private readonly Func> _loadImage; 17 | private readonly Action _log; 18 | 19 | /// 20 | /// Create a SrcSet Manager with the default parameters: 21 | /// - ImageSharp's async image loading 22 | /// - Output resulting filenames to console 23 | /// 24 | public SrcSetManager() : this(s => Image.LoadAsync(s), Console.WriteLine) 25 | { 26 | } 27 | 28 | /// 29 | /// Create a SrcSet Manager 30 | /// 31 | /// Function that turns a stream into an ImageSharp image 32 | /// Action that logs resulting output 33 | public SrcSetManager(Func> loadImage, Action log) 34 | { 35 | _loadImage = loadImage; 36 | _log = log; 37 | } 38 | 39 | /// 40 | /// Saves a set 41 | /// 42 | /// The file path of the image to be resized 43 | /// The list of widths (in pixels) 44 | public async Task SaveSrcSet(string filePath, IEnumerable widths) 45 | { 46 | var stream = File.OpenRead(filePath); 47 | var image = await _loadImage(stream); 48 | var tasks = widths.Select(width => Resize(filePath, image, image.Size.Resize(width))); 49 | await Task.WhenAll(tasks); 50 | } 51 | 52 | private async Task Resize(string filePath, Image image, Size newSize) 53 | { 54 | var resized = image.Resize(newSize); 55 | var newFile = await resized.Save(filePath); 56 | if (newFile != null) 57 | _log(newFile); 58 | } 59 | } -------------------------------------------------------------------------------- /SrcSet.Core/SupportedFormats.cs: -------------------------------------------------------------------------------- 1 | using SixLabors.ImageSharp.Formats; 2 | using SixLabors.ImageSharp.Formats.Bmp; 3 | using SixLabors.ImageSharp.Formats.Gif; 4 | using SixLabors.ImageSharp.Formats.Jpeg; 5 | using SixLabors.ImageSharp.Formats.Png; 6 | using SixLabors.ImageSharp.Formats.Tga; 7 | 8 | namespace SrcSet.Core; 9 | 10 | public static class SupportedFormats 11 | { 12 | public static readonly IImageFormat[] Default = 13 | { 14 | BmpFormat.Instance, 15 | JpegFormat.Instance, 16 | GifFormat.Instance, 17 | PngFormat.Instance, 18 | TgaFormat.Instance 19 | }; 20 | } -------------------------------------------------------------------------------- /SrcSet.Statiq.Tests/CreateResponsiveImagesTests.cs: -------------------------------------------------------------------------------- 1 | using SixLabors.ImageSharp; 2 | using SixLabors.ImageSharp.PixelFormats; 3 | using Statiq.Testing; 4 | using Xunit; 5 | 6 | namespace SrcSet.Statiq.Tests; 7 | 8 | public class CreateResponsiveImagesTests 9 | { 10 | [Fact] 11 | public async Task ImageIsLoadedOnceAndResizedCorrectNumberOfTimes() 12 | { 13 | //arrange 14 | var doc = new TestDocument(new NormalizedPath("input/test.png")); 15 | var context = new TestExecutionContext(); 16 | context.SetInputs(doc); 17 | 18 | Image image = new Image(4, 4); 19 | var loaded = 0; 20 | 21 | Task Loader(Stream stream) 22 | { 23 | loaded++; 24 | return Task.FromResult(image); 25 | } 26 | 27 | var module = new CreateResponsiveImages(Loader, new ushort[] { 1, 2, 3 }); 28 | 29 | //act 30 | var docs = await module.ExecuteAsync(context); 31 | 32 | //assert 33 | Assert.Equal(1, loaded); 34 | Assert.Equal(3, docs.Count()); 35 | } 36 | 37 | [Fact] 38 | public async Task ImageIsNotLoadedIfAllSizesExist() 39 | { 40 | //arrange 41 | var doc = new TestDocument(new NormalizedPath("input/test.png")); 42 | var context = new TestExecutionContext(); 43 | context.SetInputs(doc); 44 | context.FileSystem.GetOutputFile("input/test-0001.png").OpenWrite(); 45 | context.FileSystem.GetOutputFile("input/test-0002.png").OpenWrite(); 46 | context.FileSystem.GetOutputFile("input/test-0003.png").OpenWrite(); 47 | 48 | var module = new CreateResponsiveImages(_ => throw new Exception(), new ushort[] { 1, 2, 3 }); 49 | 50 | //act 51 | var docs = await module.ExecuteAsync(context); 52 | 53 | //assert 54 | Assert.Equal(3, docs.Count()); 55 | } 56 | 57 | [Fact] 58 | public async Task ImageIsOnlyResizedForNewSizes() 59 | { 60 | //arrange 61 | var doc = new TestDocument(new NormalizedPath("input/test.png")); 62 | var context = new TestExecutionContext(); 63 | context.SetInputs(doc); 64 | context.FileSystem.GetOutputFile("input/test-0002.png").OpenWrite(); 65 | 66 | Image image = new Image(4, 4); 67 | 68 | var module = new CreateResponsiveImages(_ => Task.FromResult(image), new ushort[] { 1, 2, 3 }); 69 | 70 | //act 71 | await module.ExecuteAsync(context); 72 | 73 | //assert 74 | Assert.DoesNotContain(context.LogMessages, l => l.FormattedMessage.Contains("Skipping input/test-0001.png")); 75 | Assert.Contains(context.LogMessages, l => l.FormattedMessage.Contains("Skipping input/test-0002.png")); 76 | Assert.DoesNotContain(context.LogMessages, l => l.FormattedMessage.Contains("Skipping input/test-0003.png")); 77 | } 78 | } -------------------------------------------------------------------------------- /SrcSet.Statiq.Tests/NormalizedPathExtensionTests.cs: -------------------------------------------------------------------------------- 1 | using Statiq.Testing; 2 | using Xunit; 3 | 4 | namespace SrcSet.Statiq.Tests; 5 | 6 | public class NormalizedPathExtensionTests 7 | { 8 | [Theory] 9 | [InlineData("test.png", true)] 10 | [InlineData("test.jpg", true)] 11 | [InlineData("test.txt", false)] 12 | public void CorrectFilesAreImages(string filename, bool expected) 13 | { 14 | //arrange 15 | var path = new NormalizedPath(filename); 16 | 17 | //act 18 | var isImage = path.IsImage(); 19 | 20 | //assert 21 | Assert.Equal(expected, isImage); 22 | } 23 | 24 | [Fact] 25 | public void CanGetDestinationFileName() 26 | { 27 | //arrange 28 | var context = new TestExecutionContext(); 29 | var path = new NormalizedPath("img/test.png"); 30 | 31 | //act 32 | var newName = path.GetDestination(123); 33 | 34 | //assert 35 | Assert.NotNull(context); 36 | Assert.Equal("img/test-0123.png", newName); 37 | } 38 | } -------------------------------------------------------------------------------- /SrcSet.Statiq.Tests/ResponsiveImagesTests.cs: -------------------------------------------------------------------------------- 1 | using Statiq.Testing; 2 | using Xunit; 3 | 4 | namespace SrcSet.Statiq.Tests; 5 | 6 | public class ResponsiveImagesTests 7 | { 8 | [Fact] 9 | public async Task FilesAreFilteredByImage() 10 | { 11 | //arrange 12 | var docs = new List 13 | { 14 | new TestDocument(new NormalizedPath("test.png")), 15 | new TestDocument(new NormalizedPath("test.txt")), 16 | new TestDocument(new NormalizedPath("test.jpg")) 17 | }; 18 | var context = new TestExecutionContext(); 19 | context.SetInputs(docs); 20 | 21 | var pipeline = new ResponsiveImages(); 22 | var filter = pipeline.ProcessModules.First(m => m is FilterDocuments); 23 | 24 | //act 25 | var output = await filter.ExecuteAsync(context); 26 | 27 | //assert 28 | var files = output.Select(doc => doc.Destination.FileName.ToString()).ToArray(); 29 | Assert.Equal(2, files.Length); 30 | Assert.Equal("test.png", files[0]); 31 | Assert.Equal("test.jpg", files[1]); 32 | } 33 | 34 | [Fact] 35 | public void CanGenerateDefaultHTML() 36 | { 37 | //arrange 38 | var context = new TestExecutionContext(); 39 | const string baseImage = "img/test.png"; 40 | 41 | //act 42 | var html = ResponsiveImages.SrcSet(baseImage); 43 | 44 | //assert 45 | Assert.NotNull(context); 46 | Assert.Equal(@"", html); 47 | } 48 | 49 | [Fact] 50 | public void CanGenerateCustomHTML() 51 | { 52 | //arrange 53 | var context = new TestExecutionContext(); 54 | const string baseImage = "img/test.png"; 55 | const ushort defaultWidth = 2; 56 | var sizes = new ushort[] { 1, 2, 3 }; 57 | var attributes = new Dictionary 58 | { 59 | { "attrX", "valX" }, 60 | { "attrY", "valY" }, 61 | { "attrZ", "valZ" }, 62 | }; 63 | 64 | //act 65 | var html = ResponsiveImages.SrcSet(baseImage, sizes, defaultWidth, attributes); 66 | 67 | //assert 68 | Assert.NotNull(context); 69 | Assert.Equal(@"", html); 70 | } 71 | } -------------------------------------------------------------------------------- /SrcSet.Statiq.Tests/SrcSet.Statiq.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | enable 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /SrcSet.Statiq/CreateResponsiveImages.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using SixLabors.ImageSharp; 3 | using SixLabors.ImageSharp.Advanced; 4 | using SrcSet.Core; 5 | 6 | namespace SrcSet.Statiq; 7 | 8 | public class CreateResponsiveImages : ParallelModule 9 | { 10 | private readonly Func> _loadImage; 11 | private readonly IEnumerable _widths; 12 | 13 | public CreateResponsiveImages(Func> loadImage, IEnumerable widths) 14 | { 15 | _loadImage = loadImage; 16 | _widths = widths; 17 | } 18 | 19 | protected override async Task> ExecuteInputAsync(IDocument input, IExecutionContext context) 20 | { 21 | var inputStream = input.GetContentStream(); 22 | var destinations = _widths.Select(w => input.Source.GetDestination(w)).ToList(); 23 | var cached = destinations.Select(d => context.FileSystem.GetOutputFile(d)).Where(f => f.Exists).ToList(); 24 | var alreadyDone = cached.Count == destinations.Count; 25 | if (alreadyDone) 26 | { 27 | context.Log(LogLevel.Debug, "Skipping {source} since all sizes already exist", input.Source); 28 | var docs = cached.Select(f => CachedDocument(input.Source, f.Path, f, context)); 29 | return await Task.WhenAll(docs); 30 | } 31 | 32 | var image = await _loadImage(inputStream); 33 | var documents = _widths.Select(w => GetResizedDocument(input.Source, image, w, context)); 34 | return await Task.WhenAll(documents); 35 | } 36 | 37 | private static async Task GetResizedDocument(NormalizedPath source, Image image, ushort width, IExecutionContext context) 38 | { 39 | var destination = source.GetDestination(width); 40 | var cached = context.FileSystem.GetOutputFile(destination); 41 | if (cached.Exists) 42 | { 43 | context.Log(LogLevel.Debug, "Skipping {destination} since it already exists", destination); 44 | return await CachedDocument(source, destination, cached, context); 45 | } 46 | 47 | var encoder = image.DetectEncoder(destination.ToString()); 48 | var output = new MemoryStream(); 49 | 50 | context.Log(LogLevel.Debug, "Resizing {source} to {destination}...", source, destination); 51 | var newSize = image.Size.Resize(width); 52 | using var resized = image.Resize(newSize); 53 | await resized.SaveAsync(output, encoder); 54 | 55 | var content = context.GetContentProvider(output); 56 | return context.CreateDocument(source, destination, content); 57 | } 58 | 59 | private static async Task CachedDocument(NormalizedPath source, NormalizedPath destination, IFile cached, IExecutionContext context) 60 | { 61 | var stream = new MemoryStream(); 62 | await cached.OpenRead().CopyToAsync(stream); 63 | var content = context.GetContentProvider(stream); 64 | return context.CreateDocument(source, context.FileSystem.GetRelativeOutputPath(destination), content); 65 | } 66 | } -------------------------------------------------------------------------------- /SrcSet.Statiq/NormalizedPathExtensions.cs: -------------------------------------------------------------------------------- 1 | using SrcSet.Core; 2 | 3 | namespace SrcSet.Statiq; 4 | 5 | public static class NormalizedPathExtensions 6 | { 7 | public static bool IsImage(this NormalizedPath path) 8 | => SrcSetManager.ValidExtensions.Contains(path.Extension); 9 | 10 | public static NormalizedPath GetDestination(this NormalizedPath source, ushort width) 11 | { 12 | var input = source.GetRelativeInputPath(); 13 | var filename = FileHelpers.GetFilename(source.FileName.ToString(), width); 14 | return input.ChangeFileName(filename); 15 | } 16 | } -------------------------------------------------------------------------------- /SrcSet.Statiq/ResponsiveImages.cs: -------------------------------------------------------------------------------- 1 | using SixLabors.ImageSharp; 2 | using SrcSet.Core; 3 | using Statiq.Web.Modules; 4 | using Statiq.Web.Pipelines; 5 | 6 | namespace SrcSet.Statiq; 7 | 8 | public class ResponsiveImages : Pipeline 9 | { 10 | /// 11 | /// Create sets of responsive images 12 | /// 13 | /// The pattern of asset files to resize 14 | /// The list of widths (in pixels) to resize the images to 15 | /// A custom image loader 16 | public ResponsiveImages(string? fileGlob = null, IEnumerable? widths = null, Func>? loadImage = null) 17 | { 18 | Dependencies.Add(nameof(Inputs)); 19 | 20 | ProcessModules = new ModuleList 21 | { 22 | new GetPipelineDocuments(ContentType.Asset), 23 | new FilterSources(Config.FromValue(fileGlob)), 24 | new FilterDocuments(Config.FromDocument(doc => doc.Source.IsImage())), 25 | new CreateResponsiveImages(loadImage ?? (s => Image.LoadAsync(s)), widths ?? SrcSetManager.DefaultSizes) 26 | }; 27 | 28 | OutputModules = new ModuleList { new WriteFiles() }; 29 | } 30 | 31 | /// 32 | /// 33 | /// 34 | /// The path (relative to the asset dir) to the original image 35 | /// The widths (in pixels) of the resized images 36 | /// The default width, for the src attribute 37 | /// Additional attributes for the img tag 38 | /// 39 | public static string SrcSet(string baseImage, IReadOnlyList? widths = null, ushort? defaultWidth = null, IDictionary? attributes = null) 40 | { 41 | widths ??= SrcSetManager.DefaultSizes.ToArray(); 42 | defaultWidth ??= widths[widths.Count / 3]; 43 | 44 | var defaultFilename = new NormalizedPath(baseImage).GetDestination(defaultWidth.Value); 45 | var srcset = widths.Select(w => SrcSetItem(baseImage, w)); 46 | var attributeStrings = attributes?.Select(a => $@"{a.Key}=""{a.Value}""") ?? ArraySegment.Empty; 47 | 48 | return $@"".Replace(" />", " />"); 49 | } 50 | 51 | private static string SrcSetItem(string baseImage, ushort width) 52 | { 53 | var path = new NormalizedPath(baseImage); 54 | var destination = path.GetDestination(width); 55 | return $"/{destination} {width}w"; 56 | } 57 | } -------------------------------------------------------------------------------- /SrcSet.Statiq/SrcSet.Statiq.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | SrcSet.Statiq 4 | Automate creating sets of responsive images for your Statiq site 5 | 5.0.0 6 | ecoAPM LLC 7 | ecoAPM LLC 8 | ecoAPM LLC 9 | SrcSet 10 | net9.0 11 | README.md 12 | https://github.com/ecoAPM/SrcSet 13 | MIT 14 | https://github.com/ecoAPM/SrcSet 15 | true 16 | true 17 | true 18 | false 19 | enable 20 | enable 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /SrcSet.Tests/ArgumentsTests.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | namespace SrcSet.Tests; 4 | 5 | public sealed class ArgumentsTests 6 | { 7 | [Fact] 8 | public void IsDirectoryIsTrueWhenGivenADirectory() 9 | { 10 | //arrange 11 | var path = Directory.GetCurrentDirectory(); 12 | 13 | //act 14 | var isDir = path.IsDirectory(); 15 | 16 | //assert 17 | Assert.True(isDir); 18 | } 19 | 20 | [Fact] 21 | public void IsDirectoryIsFalseWhenGivenAFile() 22 | { 23 | //arrange 24 | const string path = "test.png"; 25 | 26 | //act 27 | var isDir = path.IsDirectory(); 28 | 29 | //assert 30 | Assert.False(isDir); 31 | } 32 | 33 | [Fact] 34 | public void CanGetFilesForSingleFile() 35 | { 36 | //arrange 37 | var arg = "test.jpg"; 38 | 39 | //act 40 | var files = arg.GetFiles(false, false); 41 | 42 | //assert 43 | var expected = new[] { "test.jpg" }; 44 | Assert.Equal(expected, files); 45 | } 46 | 47 | [Fact] 48 | public void CanGetFilesForDirectory() 49 | { 50 | //arrange 51 | var arg = Directory.GetCurrentDirectory(); 52 | 53 | //act 54 | var files = arg.GetFiles(true, true); 55 | 56 | //assert 57 | Assert.Contains(Path.Join(arg, "test.png"), files); 58 | } 59 | } -------------------------------------------------------------------------------- /SrcSet.Tests/ProgramTests.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | namespace SrcSet.Tests; 4 | 5 | public sealed class ProgramTests 6 | { 7 | [Fact] 8 | public async Task CanResizeImage() 9 | { 10 | //arrange 11 | var args = new[] { "test.png", "1" }; 12 | 13 | //act 14 | var result = await Program.Main(args); 15 | 16 | //assert 17 | Assert.Equal(0, result); 18 | } 19 | 20 | [Fact] 21 | public async Task ShowsErrorWhenNotFound() 22 | { 23 | //arrange 24 | var args = new[] { "abc" }; 25 | 26 | //act 27 | var result = await Program.Main(args); 28 | 29 | //assert 30 | Assert.Equal(1, result); 31 | } 32 | 33 | [Fact] 34 | public async Task ShowsErrorWhenRecursiveOnFile() 35 | { 36 | //arrange 37 | var args = new[] { "test.png", "-r" }; 38 | 39 | //act 40 | var result = await Program.Main(args); 41 | 42 | //assert 43 | Assert.Equal(1, result); 44 | } 45 | } -------------------------------------------------------------------------------- /SrcSet.Tests/SrcSet.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | enable 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /SrcSet.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26228.9 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SrcSet", "SrcSet\SrcSet.csproj", "{0ED0C6E4-EA04-48FC-9DD5-C20C9A5652CC}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SrcSet.Tests", "SrcSet.Tests\SrcSet.Tests.csproj", "{DD3E0395-9021-466F-83D3-72019CA6423E}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SrcSet.Core.Tests", "SrcSet.Core.Tests\SrcSet.Core.Tests.csproj", "{F171E55B-A035-4E0A-BEA2-EAEB1E2143A1}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SrcSet.Core", "SrcSet.Core\SrcSet.Core.csproj", "{7C4EC3CA-77BB-48EB-A534-0440F2D15492}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SrcSet.Statiq", "SrcSet.Statiq\SrcSet.Statiq.csproj", "{C60B150D-510B-4E07-9A5E-BD148C0A638D}" 15 | EndProject 16 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SrcSet.Statiq.Tests", "SrcSet.Statiq.Tests\SrcSet.Statiq.Tests.csproj", "{6ABABAD2-FFC5-430B-B9BC-690E502CF11A}" 17 | EndProject 18 | Global 19 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 20 | Debug|Any CPU = Debug|Any CPU 21 | Release|Any CPU = Release|Any CPU 22 | EndGlobalSection 23 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 24 | {0ED0C6E4-EA04-48FC-9DD5-C20C9A5652CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {0ED0C6E4-EA04-48FC-9DD5-C20C9A5652CC}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {0ED0C6E4-EA04-48FC-9DD5-C20C9A5652CC}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {0ED0C6E4-EA04-48FC-9DD5-C20C9A5652CC}.Release|Any CPU.Build.0 = Release|Any CPU 28 | {DD3E0395-9021-466F-83D3-72019CA6423E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {DD3E0395-9021-466F-83D3-72019CA6423E}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {DD3E0395-9021-466F-83D3-72019CA6423E}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {DD3E0395-9021-466F-83D3-72019CA6423E}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {F171E55B-A035-4E0A-BEA2-EAEB1E2143A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {F171E55B-A035-4E0A-BEA2-EAEB1E2143A1}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {F171E55B-A035-4E0A-BEA2-EAEB1E2143A1}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {F171E55B-A035-4E0A-BEA2-EAEB1E2143A1}.Release|Any CPU.Build.0 = Release|Any CPU 36 | {7C4EC3CA-77BB-48EB-A534-0440F2D15492}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 37 | {7C4EC3CA-77BB-48EB-A534-0440F2D15492}.Debug|Any CPU.Build.0 = Debug|Any CPU 38 | {7C4EC3CA-77BB-48EB-A534-0440F2D15492}.Release|Any CPU.ActiveCfg = Release|Any CPU 39 | {7C4EC3CA-77BB-48EB-A534-0440F2D15492}.Release|Any CPU.Build.0 = Release|Any CPU 40 | {C60B150D-510B-4E07-9A5E-BD148C0A638D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 41 | {C60B150D-510B-4E07-9A5E-BD148C0A638D}.Debug|Any CPU.Build.0 = Debug|Any CPU 42 | {C60B150D-510B-4E07-9A5E-BD148C0A638D}.Release|Any CPU.ActiveCfg = Release|Any CPU 43 | {C60B150D-510B-4E07-9A5E-BD148C0A638D}.Release|Any CPU.Build.0 = Release|Any CPU 44 | {6ABABAD2-FFC5-430B-B9BC-690E502CF11A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 45 | {6ABABAD2-FFC5-430B-B9BC-690E502CF11A}.Debug|Any CPU.Build.0 = Debug|Any CPU 46 | {6ABABAD2-FFC5-430B-B9BC-690E502CF11A}.Release|Any CPU.ActiveCfg = Release|Any CPU 47 | {6ABABAD2-FFC5-430B-B9BC-690E502CF11A}.Release|Any CPU.Build.0 = Release|Any CPU 48 | EndGlobalSection 49 | GlobalSection(SolutionProperties) = preSolution 50 | HideSolutionNode = FALSE 51 | EndGlobalSection 52 | GlobalSection(ExtensibilityGlobals) = postSolution 53 | SolutionGuid = {317CDA3E-05A2-46BC-B8AB-65BF54458437} 54 | EndGlobalSection 55 | EndGlobal 56 | -------------------------------------------------------------------------------- /SrcSet/App.cs: -------------------------------------------------------------------------------- 1 | using SixLabors.ImageSharp; 2 | using SrcSet.Core; 3 | 4 | namespace SrcSet; 5 | 6 | public class App 7 | { 8 | private readonly Action _log; 9 | 10 | public App(Action log) 11 | { 12 | _log = log; 13 | } 14 | 15 | public async Task Run(Settings settings) 16 | { 17 | if (!File.Exists(settings.Path) && !Directory.Exists(settings.Path)) 18 | { 19 | _log($"Could not find file or directory named \"{settings.Path}\""); 20 | return 1; 21 | } 22 | 23 | var resizeDirectory = settings.Path.IsDirectory(); 24 | if (settings.Recursive && !resizeDirectory) 25 | { 26 | _log($"\"{Arguments.RecursiveFlag}\" can only be used with a directory"); 27 | return 1; 28 | } 29 | 30 | var manager = new SrcSetManager(s => Image.LoadAsync(s), _log); 31 | var resizeTasks = settings.Path 32 | .GetFiles(settings.Recursive, resizeDirectory) 33 | .Select(file => manager.SaveSrcSet(file, settings.Sizes)); 34 | await Task.WhenAll(resizeTasks); 35 | return 0; 36 | } 37 | } -------------------------------------------------------------------------------- /SrcSet/Arguments.cs: -------------------------------------------------------------------------------- 1 | using SrcSet.Core; 2 | 3 | namespace SrcSet; 4 | 5 | public static class Arguments 6 | { 7 | public const string RecursiveFlag = "-r"; 8 | 9 | public static bool IsDirectory(this string fileOrDirectoryArg) 10 | => File.GetAttributes(fileOrDirectoryArg) 11 | .HasFlag(FileAttributes.Directory); 12 | 13 | public static IEnumerable GetFiles(this string fileOrDirectoryArg, bool resizeRecursively, bool resizeDirectory) 14 | { 15 | var searchOption = resizeRecursively 16 | ? SearchOption.AllDirectories 17 | : SearchOption.TopDirectoryOnly; 18 | return resizeDirectory 19 | ? Directory.EnumerateFiles(fileOrDirectoryArg, "*.*", searchOption).Where(f => SrcSetManager.ValidExtensions.Contains(Path.GetExtension(f).ToLower())) 20 | : new[] { fileOrDirectoryArg }; 21 | } 22 | } -------------------------------------------------------------------------------- /SrcSet/Command.cs: -------------------------------------------------------------------------------- 1 | using Spectre.Console; 2 | using Spectre.Console.Cli; 3 | 4 | namespace SrcSet; 5 | 6 | public class Command : AsyncCommand 7 | { 8 | private readonly IAnsiConsole _console; 9 | 10 | public Command(IAnsiConsole console) 11 | => _console = console; 12 | 13 | public override async Task ExecuteAsync(CommandContext context, Settings settings) 14 | => await _console.Status().StartAsync("Running...", _ => Run(settings)); 15 | 16 | private async Task Run(Settings settings) 17 | => await Factory.App(_console).Run(settings); 18 | } -------------------------------------------------------------------------------- /SrcSet/Factory.cs: -------------------------------------------------------------------------------- 1 | using Spectre.Console; 2 | 3 | namespace SrcSet; 4 | 5 | public static class Factory 6 | { 7 | public static App App(IAnsiConsole console) 8 | => new App(console.WriteLine); 9 | } -------------------------------------------------------------------------------- /SrcSet/Program.cs: -------------------------------------------------------------------------------- 1 | using Spectre.Console.Cli; 2 | 3 | namespace SrcSet; 4 | 5 | public static class Program 6 | { 7 | public static async Task Main(string[] args) 8 | => await new CommandApp().RunAsync(args); 9 | } -------------------------------------------------------------------------------- /SrcSet/Settings.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using Spectre.Console.Cli; 3 | using SrcSet.Core; 4 | 5 | namespace SrcSet; 6 | 7 | public class Settings : CommandSettings 8 | { 9 | [CommandArgument(0, "")] 10 | [Description("the file or directory to resize")] 11 | public string Path { get; set; } = null!; 12 | 13 | [CommandOption("-r|--recursive")] 14 | [Description("recurse subdirectories")] 15 | public bool Recursive { get; set; } 16 | 17 | [CommandArgument(1, "[sizes]")] 18 | [Description("the set of widths to resize the image(s) to")] 19 | public ushort[] Sizes { get; set; } = SrcSetManager.DefaultSizes; 20 | } -------------------------------------------------------------------------------- /SrcSet/SrcSet.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | SrcSet 5 | A CLI to create sets of responsive images for the web 6 | 5.0.0 7 | ecoAPM LLC 8 | ecoAPM LLC 9 | ecoAPM LLC 10 | SrcSet 11 | srcset 12 | Exe 13 | true 14 | net9.0 15 | README.md 16 | https://github.com/ecoAPM/SrcSet 17 | MIT 18 | https://github.com/ecoAPM/SrcSet 19 | true 20 | true 21 | true 22 | enable 23 | enable 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /test-0001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecoAPM/SrcSet/599218c338a887fe45bae0b3e1a95639479d2634/test-0001.png -------------------------------------------------------------------------------- /test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecoAPM/SrcSet/599218c338a887fe45bae0b3e1a95639479d2634/test.png --------------------------------------------------------------------------------