Skip to content
This repository has been archived by the owner on Feb 25, 2021. It is now read-only.

External content packages #247

Merged
merged 6 commits into from
Mar 13, 2018
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Blazor.sln
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.VisualStudio.Lang
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.VisualStudio.BlazorExtension", "tooling\Microsoft.VisualStudio.BlazorExtension\Microsoft.VisualStudio.BlazorExtension.csproj", "{9088E4E4-B855-457F-AE9E-D86709A5E1F4}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestContentPackage", "test\testapps\TestContentPackage\TestContentPackage.csproj", "{C57382BC-EE93-49D5-BC40-5C98AF8AA048}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -301,6 +303,14 @@ Global
{9088E4E4-B855-457F-AE9E-D86709A5E1F4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9088E4E4-B855-457F-AE9E-D86709A5E1F4}.Release|Any CPU.Build.0 = Release|Any CPU
{9088E4E4-B855-457F-AE9E-D86709A5E1F4}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU
{C57382BC-EE93-49D5-BC40-5C98AF8AA048}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C57382BC-EE93-49D5-BC40-5C98AF8AA048}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C57382BC-EE93-49D5-BC40-5C98AF8AA048}.DebugNoVSIX|Any CPU.ActiveCfg = Debug|Any CPU
{C57382BC-EE93-49D5-BC40-5C98AF8AA048}.DebugNoVSIX|Any CPU.Build.0 = Debug|Any CPU
{C57382BC-EE93-49D5-BC40-5C98AF8AA048}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C57382BC-EE93-49D5-BC40-5C98AF8AA048}.Release|Any CPU.Build.0 = Release|Any CPU
{C57382BC-EE93-49D5-BC40-5C98AF8AA048}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU
{C57382BC-EE93-49D5-BC40-5C98AF8AA048}.ReleaseNoVSIX|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -340,6 +350,7 @@ Global
{FF25111E-5A3E-48A3-96D8-08A2C5A2A91C} = {ADA3AE29-F6DE-49F6-8C7C-B321508CAE8E}
{43E39257-7DC1-46BD-9BD9-2319A1313D07} = {F563ABB6-85FB-4CFC-B0D2-1D5130E8246D}
{9088E4E4-B855-457F-AE9E-D86709A5E1F4} = {F563ABB6-85FB-4CFC-B0D2-1D5130E8246D}
{C57382BC-EE93-49D5-BC40-5C98AF8AA048} = {4AE0D35B-D97A-44D0-8392-C9240377DCCE}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {504DA352-6788-4DC0-8705-82167E72A4D3}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ public static void Command(CommandLineApplication command)
"The path from the _bin folder to a given referenced dll file (Typically just the dll name)",
CommandOptionType.MultipleValue);

var jsReferences = command.Option("--js",
"Adds a <script> tag with the specified 'src' value",
CommandOptionType.MultipleValue);

var cssReferences = command.Option("--css",
"Adds a <link rel=stylesheet> tag with the specified 'href' value",
CommandOptionType.MultipleValue);

var outputPath = command.Option("--output",
"Path to the output file",
CommandOptionType.SingleValue);
Expand All @@ -36,7 +44,13 @@ public static void Command(CommandLineApplication command)
try
{
IndexHtmlWriter.UpdateIndex(clientPage.Value(), mainAssemblyPath.Value, references.Values.ToArray(), outputPath.Value());
IndexHtmlWriter.UpdateIndex(
clientPage.Value(),
mainAssemblyPath.Value,
references.Values.ToArray(),
jsReferences.Values.ToArray(),
cssReferences.Values.ToArray(),
outputPath.Value());
return 0;
}
catch (Exception ex)
Expand Down
35 changes: 31 additions & 4 deletions src/Microsoft.AspNetCore.Blazor.Build/Core/IndexHtmlWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ namespace Microsoft.AspNetCore.Blazor.Build
{
internal class IndexHtmlWriter
{
public static void UpdateIndex(string path, string assemblyPath, IEnumerable<string> references, string outputPath)
public static void UpdateIndex(
string path,
string assemblyPath,
IEnumerable<string> assemblyReferences,
IEnumerable<string> jsReferences,
IEnumerable<string> cssReferences,
string outputPath)
{
var template = GetTemplate(path);
if (template == null)
Expand All @@ -24,7 +30,7 @@ public static void UpdateIndex(string path, string assemblyPath, IEnumerable<str
}
var assemblyName = Path.GetFileNameWithoutExtension(assemblyPath);
var entryPoint = GetAssemblyEntryPoint(assemblyPath);
var updatedContent = GetIndexHtmlContents(template, assemblyName, entryPoint, references);
var updatedContent = GetIndexHtmlContents(template, assemblyName, entryPoint, assemblyReferences, jsReferences, cssReferences);
var normalizedOutputPath = Normalize(outputPath);
Console.WriteLine("Writing index to: " + normalizedOutputPath);
File.WriteAllText(normalizedOutputPath, updatedContent);
Expand Down Expand Up @@ -93,7 +99,9 @@ public static string GetIndexHtmlContents(
string htmlTemplate,
string assemblyName,
string assemblyEntryPoint,
IEnumerable<string> binFiles)
IEnumerable<string> assemblyReferences,
IEnumerable<string> jsReferences,
IEnumerable<string> cssReferences)
{
var resultBuilder = new StringBuilder();

Expand Down Expand Up @@ -132,9 +140,19 @@ public static string GetIndexHtmlContents(
resultBuilder,
assemblyName,
assemblyEntryPoint,
binFiles,
assemblyReferences,
tag.Attributes);

// Emit tags to reference any specified JS/CSS files
AppendReferenceTags(
resultBuilder,
cssReferences,
"<link rel=\"stylesheet\" href=\"{0}\" />");
AppendReferenceTags(
resultBuilder,
jsReferences,
"<script src=\"{0}\" defer></script>");

// Set a flag so we know not to emit anything else until the special
// tag is closed
isInBlazorBootTag = true;
Expand All @@ -160,6 +178,15 @@ public static string GetIndexHtmlContents(
}
}

private static void AppendReferenceTags(StringBuilder resultBuilder, IEnumerable<string> urls, string format)
{
foreach (var url in urls)
{
resultBuilder.AppendLine();
resultBuilder.AppendFormat(format, url);
}
}

private static bool IsBlazorBootTag(HtmlTagToken tag)
=> string.Equals(tag.Name, "script", StringComparison.Ordinal)
&& tag.Attributes.Any(pair =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

<PropertyGroup Label="Blazor build outputs">
<AdditionalMonoLinkerOptions>-c link -u link -t --verbose </AdditionalMonoLinkerOptions>
<BaseBlazorPackageContentOutputPath>dist/_content/</BaseBlazorPackageContentOutputPath>
<BaseBlazorRuntimeOutputPath>dist/_framework/</BaseBlazorRuntimeOutputPath>
<BaseBlazorRuntimeBinOutputPath>$(BaseBlazorRuntimeOutputPath)_bin/</BaseBlazorRuntimeBinOutputPath>
<BaseBlazorRuntimeAsmjsOutputPath>$(BaseBlazorRuntimeOutputPath)asmjs/</BaseBlazorRuntimeAsmjsOutputPath>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,14 @@
</BlazorItemOutput>
</ItemGroup>

<ItemGroup Label="Static content supplied by NuGet packages">
<_BlazorPackageContentOutput Include="@(BlazorPackageContentFile)" Condition="%(SourcePackage) != ''">
<TargetOutputPath>$(ProjectDir)$(OutputPath)$(BaseBlazorPackageContentOutputPath)%(SourcePackage)\%(RecursiveDir)\%(Filename)%(Extension)</TargetOutputPath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this does anything on custom items. I think this is a feature of Content. This is good for now, but in the future I think we should move users to use Content for this (as it has all the features). I would need to know more about what we are trying to do here to better judge it, but I'm going to make 2 assumptions.

  1. Content from 3rd party packages is immutable.
  2. Just needs to be copied to the output without any prior processing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BlazorPackageContentFile items are those specifically nominated by the package for inclusion in the dist dir.

Content from 3rd party packages is immutable.

Yes

Just needs to be copied to the output without any prior processing.

Pretty much, but:

  1. We want to output it to dist, not just $(OutDir) (and don't want package authors to hardcode the notion of dist in their packages)
  2. We want to strongly guide package authors to give us their SourcePackage metadata so that we can drop content into dist/_content/(SourcePackage)/... to ensure there are no clashes across packages. The implementation here means you have to give a nonempty SourcePackage otherwise we won't include your item in dist.

</_BlazorPackageContentOutput>
<BlazorItemOutput Include="@(_BlazorPackageContentOutput)" />
</ItemGroup>

<PropertyGroup Label="Intermediate output paths">

<!-- /obj/<<configuration>>/<<targetframework>>/blazor -->
Expand Down Expand Up @@ -522,6 +530,8 @@
<ItemGroup>
<BlazorIndexHtmlInput Include="$(BlazorIndexHtml)" />
<BlazorIndexHtmlInput Include="@(BlazorItemOutput->WithMetadataValue('Type','Assembly')->'%(FullPath)')" />
<BlazorIndexHtmlInput Include="@(BlazorPackageJsRef->'%(FullPath)')" />
<BlazorIndexHtmlInput Include="@(BlazorPackageCssRef->'%(FullPath)')" />
</ItemGroup>

<WriteLinesToFile
Expand All @@ -543,9 +553,11 @@
Outputs="$(BlazorIndexHtmlOutputPath)">
<ItemGroup>
<_AppReferences Include="@(BlazorItemOutput->WithMetadataValue('Type','Assembly')->WithMetadataValue('PrimaryOutput','')->'%(FileName)%(Extension)')" />
<_JsReferences Include="@(BlazorPackageJsRef->'_content/%(SourcePackage)/%(RecursiveDir)%(FileName)%(Extension)')" />
<_CssReferences Include="@(BlazorPackageCssRef->'_content/%(SourcePackage)/%(RecursiveDir)%(FileName)%(Extension)')" />
</ItemGroup>

<Exec Command="$(BlazorBuildExe) build @(IntermediateAssembly) --html-page &quot;$(BlazorIndexHtml)&quot; @(_AppReferences->'--reference %(Identity)', ' ') --output &quot;$(BlazorIndexHtmlOutputPath)&quot;" />
<Exec Command="$(BlazorBuildExe) build @(IntermediateAssembly) --html-page &quot;$(BlazorIndexHtml)&quot; @(_AppReferences->'--reference %(Identity)', ' ') @(_JsReferences->'--js %(Identity)', ' ') @(_CssReferences->'--css %(Identity)', ' ') --output &quot;$(BlazorIndexHtmlOutputPath)&quot;" />

<ItemGroup Condition="Exists('$(BlazorIndexHtmlOutputPath)')">
<_BlazorIndex Include="$(BlazorIndexHtmlOutputPath)" />
Expand Down
38 changes: 25 additions & 13 deletions test/Microsoft.AspNetCore.Blazor.Build.Test/IndexHtmlWriterTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using AngleSharp.Parser.Html;
using System.Linq;
using Xunit;

namespace Microsoft.AspNetCore.Blazor.Build.Test
Expand All @@ -25,15 +26,16 @@ Some text
$@"{htmlTemplatePrefix}
<script type='blazor-boot' custom1 custom2=""value"">some text that should be removed</script>
{htmlTemplateSuffix}";
var dependencies = new string[]
{
"System.Abc.dll",
"MyApp.ClassLib.dll",
};
var assemblyReferences = new string[] { "System.Abc.dll", "MyApp.ClassLib.dll", };
var jsReferences = new string[] { "some/file.js", "another.js" };
var cssReferences = new string[] { "my/styles.css" };
var instance = IndexHtmlWriter.GetIndexHtmlContents(
htmlTemplate,
"MyApp.Entrypoint",
"MyNamespace.MyType::MyMethod", dependencies);
"MyNamespace.MyType::MyMethod",
assemblyReferences,
jsReferences,
cssReferences);

// Act & Assert: Start and end is not modified (including formatting)
Assert.StartsWith(htmlTemplatePrefix, instance);
Expand All @@ -42,7 +44,9 @@ Some text
// Assert: Boot tag is correct
var scriptTagText = instance.Substring(htmlTemplatePrefix.Length, instance.Length - htmlTemplatePrefix.Length - htmlTemplateSuffix.Length);
var parsedHtml = new HtmlParser().Parse("<html><body>" + scriptTagText + "</body></html>");
var scriptElem = parsedHtml.Body.QuerySelector("script");
var scriptElems = parsedHtml.Body.QuerySelectorAll("script");
var linkElems = parsedHtml.Body.QuerySelectorAll("link");
var scriptElem = scriptElems[0];
Assert.False(scriptElem.HasChildNodes);
Assert.Equal("_framework/blazor.js", scriptElem.GetAttribute("src"));
Assert.Equal("MyApp.Entrypoint.dll", scriptElem.GetAttribute("main"));
Expand All @@ -51,21 +55,29 @@ Some text
Assert.False(scriptElem.HasAttribute("type"));
Assert.Equal(string.Empty, scriptElem.Attributes["custom1"].Value);
Assert.Equal("value", scriptElem.Attributes["custom2"].Value);

// Assert: Also contains script tags referencing JS files
Assert.Equal(
scriptElems.Skip(1).Select(tag => tag.GetAttribute("src")),
jsReferences);

// Assert: Also contains link tags referencing CSS files
Assert.Equal(
linkElems.Select(tag => tag.GetAttribute("href")),
cssReferences);
}

[Fact]
public void SuppliesHtmlTemplateUnchangedIfNoBootScriptPresent()
{
// Arrange
var htmlTemplate = "<!DOCTYPE html><html><body><h1 style='color:red'>Hello</h1>Some text<script type='irrelevant'>blah</script></body></html>";
var dependencies = new string[]
{
"System.Abc.dll",
"MyApp.ClassLib.dll",
};
var assemblyReferences = new string[] { "System.Abc.dll", "MyApp.ClassLib.dll" };
var jsReferences = new string[] { "some/file.js", "another.js" };
var cssReferences = new string[] { "my/styles.css" };

var content = IndexHtmlWriter.GetIndexHtmlContents(
htmlTemplate, "MyApp.Entrypoint", "MyNamespace.MyType::MyMethod", dependencies);
htmlTemplate, "MyApp.Entrypoint", "MyNamespace.MyType::MyMethod", assemblyReferences, jsReferences, cssReferences);

// Assert
Assert.Equal(htmlTemplate, content);
Expand Down
6 changes: 6 additions & 0 deletions test/testapps/BasicTestApp/BasicTestApp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,10 @@
<ProjectReference Include="..\..\..\src\Microsoft.AspNetCore.Blazor\Microsoft.AspNetCore.Blazor.csproj" />
</ItemGroup>

<!-- Local alternative to <PackageReference> to the content package -->
<Import Project="..\TestContentPackage\build\TestContentPackage.props" />
<ItemGroup>
<ProjectReference Include="..\TestContentPackage\TestContentPackage.csproj" />
</ItemGroup>

</Project>
39 changes: 39 additions & 0 deletions test/testapps/BasicTestApp/ExternalContentPackage.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
@using TestContentPackage

<h1>Functionality and content from an external package</h1>

<p>
NuGet packages can embed .NET code, which can in turn call Blazor's
JS interop features if desired. This can be used to distribute new
browser APIs as NuGet packages.
</p>

<p>Click the following button to invoke a JavaScript function.</p>

<button @onclick(ShowJavaScriptPrompt)>Show JavaScript prompt</button>

@if (!string.IsNullOrEmpty(result))
{
<p>Result: <strong>@result</strong></p>
}

<hr />

<p>
Additionally, NuGet packages can contain static resources such as CSS
files and images.
</p>

<div class="special-style">
The elegant styling on this &lt;div&gt; comes from the external content package.
</div>

@functions
{
string result;

void ShowJavaScriptPrompt()
{
result = MyPrompt.Show("Hello!");
}
}
1 change: 1 addition & 0 deletions test/testapps/BasicTestApp/wwwroot/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<option value="BasicTestApp.HierarchicalImportsTest.Subdir.ComponentUsingImports">Imports statement</option>
<option value="BasicTestApp.HttpClientTest.HttpRequestsComponent">HttpClient tester</option>
<option value="BasicTestApp.BindCasesComponent">@bind cases</option>
<option value="BasicTestApp.ExternalContentPackage">External content package</option>
<!--<option value="BasicTestApp.RouterTest.Default">Router</option> Excluded because it requires additional setup to work correctly when loaded manually -->
</select>
&nbsp;
Expand Down
17 changes: 17 additions & 0 deletions test/testapps/TestContentPackage/MyPrompt.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Blazor.Browser.Interop;

namespace TestContentPackage
{
public static class MyPrompt
{
// Keep in sync with the identifier in the .js file
const string ShowPromptIdentifier = "TestContentPackage.showPrompt";

public static string Show(string message)
{
return RegisteredFunction.Invoke<string>(
ShowPromptIdentifier,
message);
}
}
}
17 changes: 17 additions & 0 deletions test/testapps/TestContentPackage/TestContentPackage.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<Content Include="build\**" PackagePath="build" />
<Content Include="content\**" PackagePath="content" />
</ItemGroup>

<ItemGroup>
<!-- In real content packages, use a <PackageReference> to Microsoft.AspNetCore.Blazor.Browser instead. -->
<ProjectReference Include="..\..\..\src\Microsoft.AspNetCore.Blazor.Browser\Microsoft.AspNetCore.Blazor.Browser.csproj" />
</ItemGroup>

</Project>
19 changes: 19 additions & 0 deletions test/testapps/TestContentPackage/build/TestContentPackage.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project>
<PropertyGroup>
<!-- Update this to match your package ID exactly -->
<_PackageId>TestContentPackage</_PackageId>
<_ContentDir>$(MSBuildThisFileDirectory)..\content\</_ContentDir>
</PropertyGroup>
<ItemGroup>
<!-- All files under "content" will be included with the Blazor app build output -->
<BlazorPackageContentFile Include="$(_ContentDir)**" SourcePackage="$(_PackageId)" />
Copy link
Member

@javiercn javiercn Mar 12, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this I would use content and an Additional metadata attribute inside the content. That way you can piggyback on all the features of content.
The only reason I didn't do this for asmjs, wasm and other static content in the blazor core outputs is because it doesn't surface to the user.

That said, this is just a suggestion, you can give content a try or just leave it like this for now.

Another completely different option can be to just define stuff in the package as content and make it go into
(wwwroot|dist)/_blazorcontent/(js|css)/** in the consuming project and have blazor include anything under _blazorcontent/(js|css)/**.* in the index.html

That way as a package author you just have to care about putting stuff in the right folder and everything else happens automatically.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another completely different option can be to just define stuff in the package as content and make it go into

@javiercn I tried to do this at some length, by putting <PackageCopyToOutput>true</PackageCopyToOutput> on the <Content> items in the source package and using PackagePath to make them land in the correct place in the output directory. It works correctly at build time.

This would a be better way to do it, except for one severe problem, which is that all <Content> items appear in the consuming project's Solution Explorer tree as files (with little "link" icons). This makes the consuming project look messy, or at least adds totally unfamiliar items to the tree.

If you know of a way of using <Content> and not having the items show up in the consuming project, please let me know! I'm guessing it's not currently possible due to NuGet/Home#4856 still being unresolved.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do believe that you can use the false node in MSBuild to make them not show up in VS https://msdn.microsoft.com/en-us/library/bb629388.aspx

So my idea is that if you put the content on the right folder path then you can have msbuild in our targets that based on the path updates the metadata for the itemgroup. (And this can be just at the top level so it applies globally)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you talking about Visible="false"? I did try that, and although it hid the files, it did not hide the directories. It looked very weird. If you're able to make this work and hide both please let me know!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SteveSandersonMS Yes. @rynowak This is also what you mentioned to me before, isn't it? To just use Content and use <Visible>false</Visible> do you know about the folders showing up?


<!-- We'll generate a <script> tag importing each of the following JavaScript files -->
<!-- Change the "Include" pattern if you don't want to include all .js files. -->
<BlazorPackageJsRef Include="$(_ContentDir)**\*.js" SourcePackage="$(_PackageId)" />

<!-- We'll generate a <link> tag importing each of the following CSS files -->
<!-- Change the "Include" pattern if you don't want to include all .css files. -->
<BlazorPackageCssRef Include="$(_ContentDir)**\*.css" SourcePackage="$(_PackageId)" />
</ItemGroup>
</Project>
Binary file added test/testapps/TestContentPackage/content/face.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions test/testapps/TestContentPackage/content/prompt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Blazor.registerFunction('TestContentPackage.showPrompt', function (message) {
return prompt(message, "Type anything here");
});
18 changes: 18 additions & 0 deletions test/testapps/TestContentPackage/content/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.special-style {
background-image: url('./face.png');
padding: 50px;
background-repeat: repeat-x;
border: 5px dashed red;
font-family: "Comic Sans MS";
font-size: 20px;
font-weight: bold;
animation: hideous-rainbow 1s infinite;
}

@keyframes hideous-rainbow {
0% { color: red; }
20% { color: orange; }
40% { color: yellow; }
60% { color: green; }
80% { color: blue; }
}