├── .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 | [](https://www.nuget.org/packages/SrcSet/)
6 | [](https://www.nuget.org/packages/SrcSet.Statiq/)
7 | [](https://www.nuget.org/packages/SrcSet.Core/)
8 |
9 | [](https://github.com/ecoAPM/SrcSet/actions)
10 | [](https://sonarcloud.io/dashboard?id=ecoAPM_SrcSet)
11 |
12 | [](https://sonarcloud.io/dashboard?id=ecoAPM_SrcSet)
13 | [](https://sonarcloud.io/dashboard?id=ecoAPM_SrcSet)
14 | [](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
--------------------------------------------------------------------------------