From 09e540ca1d9e680c4bff9514867df7afd176920d Mon Sep 17 00:00:00 2001
From: bounav <benjamin.fourmond@comsechq.com>
Date: Thu, 29 Feb 2024 16:26:59 +0000
Subject: [PATCH] New InMemoryCacheService implementation of ICacheService in
 Spark

- InMemoryCacheService cache can be used in any .net standard project
- CacheExpires class not longer has depenency on System.Web.Caching.Cache
- Renamed DefaultCacheService to WebCacheService
- Moved NullCacheService to Castle.MonoRail.Views project (it's the only place using it)
- Moved some classes back to Spark project when possible
- Moved markdown dependency back to spark
---
 .../NullCacheService.cs                       |  0
 .../Wrappers/HybridCacheService.cs            |  2 +-
 src/Spark.Tests/InMemoryServiceTest.cs        | 88 +++++++++++++++++++
 .../Extensions/ServiceCollectionExtensions.cs |  2 +-
 .../Caching/CacheElementTester.cs             |  1 -
 src/Spark.Web/CacheExpires.cs                 | 39 --------
 .../Caching/SpoolWriterOriginator.cs          | 38 --------
 .../Caching/StringWriterOriginator.cs         | 39 --------
 src/Spark.Web/Spark.Web.csproj                |  3 -
 ...aultCacheService.cs => WebCacheService.cs} | 14 +--
 src/{Spark.Web => Spark}/AbstractSparkView.cs |  0
 src/Spark/CacheExpires.cs                     | 60 +++++++++++++
 src/{Spark.Web => Spark}/CacheSignal.cs       | 12 +--
 .../Caching/CacheMemento.cs                   | 13 +--
 .../Caching/CacheOriginator.cs                | 39 ++++----
 src/Spark/Caching/SpoolWriterOriginator.cs    | 31 +++++++
 src/Spark/Caching/StringWriterOriginator.cs   | 32 +++++++
 .../Caching/TextWriterOriginator.cs           |  6 ++
 .../ChunkVisitors/GeneratedCodeVisitor.cs     |  1 +
 .../DetectCodeExpressionVisitor.cs            |  7 +-
 src/{Spark.Web => Spark}/ICacheService.cs     |  2 -
 src/Spark/InMemoryCacheService.cs             | 71 +++++++++++++++
 src/Spark/Spark.csproj                        |  6 +-
 src/{Spark.Web => Spark}/SparkViewBase.cs     |  0
 .../SparkViewDecorator.cs                     |  0
 25 files changed, 330 insertions(+), 176 deletions(-)
 rename src/{Spark.Web => Castle.MonoRail.Views.Spark}/NullCacheService.cs (100%)
 create mode 100644 src/Spark.Tests/InMemoryServiceTest.cs
 delete mode 100644 src/Spark.Web/CacheExpires.cs
 delete mode 100644 src/Spark.Web/Caching/SpoolWriterOriginator.cs
 delete mode 100644 src/Spark.Web/Caching/StringWriterOriginator.cs
 rename src/Spark.Web/{Caching/DefaultCacheService.cs => WebCacheService.cs} (83%)
 rename src/{Spark.Web => Spark}/AbstractSparkView.cs (100%)
 create mode 100644 src/Spark/CacheExpires.cs
 rename src/{Spark.Web => Spark}/CacheSignal.cs (92%)
 rename src/{Spark.Web => Spark}/Caching/CacheMemento.cs (60%)
 rename src/{Spark.Web => Spark}/Caching/CacheOriginator.cs (71%)
 create mode 100644 src/Spark/Caching/SpoolWriterOriginator.cs
 create mode 100644 src/Spark/Caching/StringWriterOriginator.cs
 rename src/{Spark.Web => Spark}/Caching/TextWriterOriginator.cs (94%)
 rename src/{Spark.Web => Spark}/ICacheService.cs (93%)
 create mode 100644 src/Spark/InMemoryCacheService.cs
 rename src/{Spark.Web => Spark}/SparkViewBase.cs (100%)
 rename src/{Spark.Web => Spark}/SparkViewDecorator.cs (100%)

diff --git a/src/Spark.Web/NullCacheService.cs b/src/Castle.MonoRail.Views.Spark/NullCacheService.cs
similarity index 100%
rename from src/Spark.Web/NullCacheService.cs
rename to src/Castle.MonoRail.Views.Spark/NullCacheService.cs
diff --git a/src/Castle.MonoRail.Views.Spark/Wrappers/HybridCacheService.cs b/src/Castle.MonoRail.Views.Spark/Wrappers/HybridCacheService.cs
index f9bab1a3..a8a36ab0 100644
--- a/src/Castle.MonoRail.Views.Spark/Wrappers/HybridCacheService.cs
+++ b/src/Castle.MonoRail.Views.Spark/Wrappers/HybridCacheService.cs
@@ -32,7 +32,7 @@ public HybridCacheService(IEngineContext context)
             }
             else
             {
-                _fallbackCacheService = new DefaultCacheService(context.UnderlyingContext.Cache);
+                _fallbackCacheService = new WebCacheService(context.UnderlyingContext.Cache);
             }
         }
 
diff --git a/src/Spark.Tests/InMemoryServiceTest.cs b/src/Spark.Tests/InMemoryServiceTest.cs
new file mode 100644
index 00000000..8599b4dd
--- /dev/null
+++ b/src/Spark.Tests/InMemoryServiceTest.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Threading;
+using Microsoft.Extensions.Caching.Memory;
+using NUnit.Framework;
+
+namespace Spark.Tests
+{
+    [TestFixture]
+    public class InMemoryServiceTest
+    {
+        [Test]
+        public void TestStoreValueThenRetrieveIt()
+        {
+            var service = new InMemoryCacheService(new MemoryCache(new MemoryCacheOptions()));
+
+            var item = new { };
+
+            service.Store("identifier", CacheExpires.Empty, null, item);
+
+            var retrieved = service.Get("identifier");
+
+            Assert.AreSame(item, retrieved);
+        }
+
+        [Test]
+        public void TestStoreValueThenRetrieveItAfterAbsoluteExpiration()
+        {
+            var service = new InMemoryCacheService(new MemoryCache(new MemoryCacheOptions()));
+
+            var item = new { };
+
+            service.Store("identifier", new CacheExpires(DateTime.UtcNow.AddMilliseconds(50)), null, item);
+
+            Thread.Sleep(100);
+
+            var retrieved = service.Get("identifier");
+
+            Assert.IsNull(retrieved);
+        }
+
+        [Test]
+        public void TestStoreValueThenRetrieveItWhenExpirationSlides()
+        {
+            var service = new InMemoryCacheService(new MemoryCache(new MemoryCacheOptions()));
+
+            var item = new { };
+
+            service.Store("identifier", new CacheExpires(TimeSpan.FromMilliseconds(75)), null, item);
+
+            object retrieved;
+
+            for (var i = 0; i < 3; i++)
+            {
+                Thread.Sleep(50);
+
+                retrieved = service.Get("identifier");
+            }
+
+            retrieved = service.Get("identifier");
+
+            Assert.IsNotNull(retrieved);
+
+            Assert.AreSame(item, retrieved);
+        }
+
+        [Test]
+        public void TestStoreValueWithSignal()
+        {
+            var service = new InMemoryCacheService(new MemoryCache(new MemoryCacheOptions()));
+
+            var item = new { };
+
+            var signal = new CacheSignal();
+
+            service.Store("identifier", null, signal, item);
+
+            var retrieved = service.Get("identifier");
+
+            Assert.AreSame(item, retrieved);
+
+            signal.FireChanged();
+
+            retrieved = service.Get("identifier");
+
+            Assert.IsNull(retrieved);
+        }
+    }
+}
diff --git a/src/Spark.Web.Mvc/Extensions/ServiceCollectionExtensions.cs b/src/Spark.Web.Mvc/Extensions/ServiceCollectionExtensions.cs
index a99a77bb..fd33e2cc 100644
--- a/src/Spark.Web.Mvc/Extensions/ServiceCollectionExtensions.cs
+++ b/src/Spark.Web.Mvc/Extensions/ServiceCollectionExtensions.cs
@@ -69,7 +69,7 @@ public static IServiceCollection AddSpark(this IServiceCollection services, ISpa
                 {
                     if (HttpContext.Current != null && HttpContext.Current.Cache != null)
                     {
-                        return new DefaultCacheService(HttpContext.Current.Cache);
+                        return new WebCacheService(HttpContext.Current.Cache);
                     }
 
                     return null;
diff --git a/src/Spark.Web.Tests/Caching/CacheElementTester.cs b/src/Spark.Web.Tests/Caching/CacheElementTester.cs
index 8624b3c1..d51b261e 100644
--- a/src/Spark.Web.Tests/Caching/CacheElementTester.cs
+++ b/src/Spark.Web.Tests/Caching/CacheElementTester.cs
@@ -553,7 +553,6 @@ public void SignalWillExpireOutputCachingEntry()
 <p>2</p>
 </div>"));
             Assert.That(calls, Is.EqualTo(2));
-
         }
     }
 }
diff --git a/src/Spark.Web/CacheExpires.cs b/src/Spark.Web/CacheExpires.cs
deleted file mode 100644
index ba91cbca..00000000
--- a/src/Spark.Web/CacheExpires.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using System;
-
-namespace Spark
-{
-    public class CacheExpires
-    {
-        private static CacheExpires _empty = new CacheExpires();
-
-        public CacheExpires()
-        {
-            Absolute = NoAbsoluteExpiration;
-            Sliding = NoSlidingExpiration;
-        }
-
-        public CacheExpires(DateTime absolute)
-        {
-            Absolute = absolute;
-            Sliding = NoSlidingExpiration;
-        }
-
-        public CacheExpires(TimeSpan sliding)
-        {
-            Absolute = NoAbsoluteExpiration;
-            Sliding = sliding;
-        }
-
-        public CacheExpires(double sliding)
-            : this(TimeSpan.FromSeconds(sliding))
-        {
-        }
-
-        public DateTime Absolute { get; set; }
-        public TimeSpan Sliding { get; set; }
-
-        public static DateTime NoAbsoluteExpiration { get { return System.Web.Caching.Cache.NoAbsoluteExpiration; } }
-        public static TimeSpan NoSlidingExpiration { get { return System.Web.Caching.Cache.NoSlidingExpiration; } }
-        public static CacheExpires Empty { get { return _empty; } }
-    }
-}
\ No newline at end of file
diff --git a/src/Spark.Web/Caching/SpoolWriterOriginator.cs b/src/Spark.Web/Caching/SpoolWriterOriginator.cs
deleted file mode 100644
index f2055cfb..00000000
--- a/src/Spark.Web/Caching/SpoolWriterOriginator.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-using System;
-using System.Linq;
-using Spark.Spool;
-
-namespace Spark.Caching
-{
-    public class SpoolWriterOriginator : TextWriterOriginator
-    {
-        private readonly SpoolWriter _writer;
-        private int _priorStringCount;
-
-        public SpoolWriterOriginator(SpoolWriter writer)
-        {
-            _writer = writer;
-        }
-
-        public override TextWriterMemento CreateMemento()
-        {
-            return new TextWriterMemento { Written = _writer.ToArray() };
-        }
-
-        public override void BeginMemento()
-        {
-            _priorStringCount = _writer.Count();
-        }
-
-        public override TextWriterMemento EndMemento()
-        {
-            return new TextWriterMemento { Written = _writer.Skip(_priorStringCount).ToArray() };
-        }
-
-        public override void DoMemento(TextWriterMemento memento)
-        {
-            foreach (var written in memento.Written)
-                _writer.Write(written);
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Spark.Web/Caching/StringWriterOriginator.cs b/src/Spark.Web/Caching/StringWriterOriginator.cs
deleted file mode 100644
index 421d0ce3..00000000
--- a/src/Spark.Web/Caching/StringWriterOriginator.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using System;
-using System.IO;
-
-namespace Spark.Caching
-{
-    public class StringWriterOriginator : TextWriterOriginator
-    {
-        private readonly StringWriter _writer;
-        private int _priorLength;
-
-        public StringWriterOriginator(StringWriter writer)
-        {
-            _writer = writer;
-        }
-
-        public override TextWriterMemento CreateMemento()
-        {
-            return new TextWriterMemento {Written = new[] {_writer.ToString()}};
-        }
-
-        public override void BeginMemento()
-        {
-            _priorLength = _writer.GetStringBuilder().Length;
-        }
-
-        public override TextWriterMemento EndMemento()
-        {
-            var currentLength = _writer.GetStringBuilder().Length;
-            var written = _writer.GetStringBuilder().ToString(_priorLength, currentLength - _priorLength);
-            return new TextWriterMemento { Written = new[] { written } };
-        }
-
-        public override void DoMemento(TextWriterMemento memento)
-        {
-            foreach(var written in memento.Written)
-                _writer.Write(written);
-        }
-    }
-}
diff --git a/src/Spark.Web/Spark.Web.csproj b/src/Spark.Web/Spark.Web.csproj
index d76470b1..136e0f29 100644
--- a/src/Spark.Web/Spark.Web.csproj
+++ b/src/Spark.Web/Spark.Web.csproj
@@ -28,9 +28,6 @@
     <Reference Include="System.Configuration" />
     <Reference Include="System.Web" />
   </ItemGroup>
-  <ItemGroup>
-    <PackageReference Include="MarkdownSharp" Version="2.0.5" />
-  </ItemGroup>
   <ItemGroup>
     <ProjectReference Include="..\Spark\Spark.csproj" />
   </ItemGroup>
diff --git a/src/Spark.Web/Caching/DefaultCacheService.cs b/src/Spark.Web/WebCacheService.cs
similarity index 83%
rename from src/Spark.Web/Caching/DefaultCacheService.cs
rename to src/Spark.Web/WebCacheService.cs
index 2a1a4785..bf31bc1f 100644
--- a/src/Spark.Web/Caching/DefaultCacheService.cs
+++ b/src/Spark.Web/WebCacheService.cs
@@ -1,25 +1,25 @@
 using System;
 using System.Web.Caching;
 
-namespace Spark.Caching
+namespace Spark
 {
-    public class DefaultCacheService : ICacheService
+    public class WebCacheService : ICacheService
     {
-        private readonly Cache _cache;
+        private readonly Cache cache;
 
-        public DefaultCacheService(Cache cache)
+        public WebCacheService(Cache cache)
         {
-            _cache = cache;
+            this.cache = cache;
         }
 
         public object Get(string identifier)
         {
-            return _cache.Get(identifier);
+            return this.cache.Get(identifier);
         }
 
         public void Store(string identifier, CacheExpires expires, ICacheSignal signal, object item)
         {
-            _cache.Insert(
+            this.cache.Insert(
                 identifier,
                 item,
                 SignalDependency.For(signal),
diff --git a/src/Spark.Web/AbstractSparkView.cs b/src/Spark/AbstractSparkView.cs
similarity index 100%
rename from src/Spark.Web/AbstractSparkView.cs
rename to src/Spark/AbstractSparkView.cs
diff --git a/src/Spark/CacheExpires.cs b/src/Spark/CacheExpires.cs
new file mode 100644
index 00000000..a9ed50cb
--- /dev/null
+++ b/src/Spark/CacheExpires.cs
@@ -0,0 +1,60 @@
+using System;
+
+namespace Spark
+{
+    /// <summary>
+    /// Represents when a cached entry should expire.
+    /// </summary>
+    public class CacheExpires
+    {
+        /// <summary>
+        /// Constructor for a non expiring cached entry.
+        /// </summary>
+        public CacheExpires()
+        {
+            Absolute = NoAbsoluteExpiration;
+            Sliding = NoSlidingExpiration;
+        }
+
+        /// <summary>
+        /// Constructor for a non cached entry expiring at a specified time.
+        /// </summary>
+        /// <param name="absolute">The time when to invalidate the cached entry.</param>
+        public CacheExpires(DateTime absolute)
+        {
+            Absolute = absolute;
+            Sliding = NoSlidingExpiration;
+        }
+
+        /// <summary>
+        /// Constructor for a cached entry that stays cached as long as it keeps being used.
+        /// </summary>
+        /// <param name="sliding">The timespan of sliding expirations.</param>
+        public CacheExpires(TimeSpan sliding)
+        {
+            Absolute = NoAbsoluteExpiration;
+            Sliding = sliding;
+        }
+
+        /// <summary>
+        /// Constructor for a cached entry that stays cached as long as it keeps being used.
+        /// </summary>
+        /// <param name="sliding">The number of seconds of sliding expirations.</param>
+        public CacheExpires(double sliding)
+            : this(TimeSpan.FromSeconds(sliding))
+        {
+        }
+
+        public DateTime Absolute { get; set; }
+        public TimeSpan Sliding { get; set; }
+
+        public static DateTime NoAbsoluteExpiration => DateTime.MaxValue;
+
+        public static TimeSpan NoSlidingExpiration => TimeSpan.Zero;
+
+        /// <summary>
+        /// Cached entry never to expire.
+        /// </summary>
+        public static CacheExpires Empty { get; } = new();
+    }
+}
\ No newline at end of file
diff --git a/src/Spark.Web/CacheSignal.cs b/src/Spark/CacheSignal.cs
similarity index 92%
rename from src/Spark.Web/CacheSignal.cs
rename to src/Spark/CacheSignal.cs
index 5e00d3ab..f4db2bd5 100644
--- a/src/Spark.Web/CacheSignal.cs
+++ b/src/Spark/CacheSignal.cs
@@ -19,7 +19,7 @@ public event EventHandler Changed
                 lock (this)
                 {
                     _changed += value;
-                    if (_enabled) 
+                    if (_enabled)
                         return;
 
                     Enable();
@@ -31,7 +31,7 @@ public event EventHandler Changed
                 lock (this)
                 {
                     _changed -= value;
-                    if (_enabled != true || ChangedIsEmpty() == false) 
+                    if (_enabled != true || ChangedIsEmpty() == false)
                         return;
 
                     Disable();
@@ -42,7 +42,7 @@ public event EventHandler Changed
 
         private bool ChangedIsEmpty()
         {
-            return _changed == null || 
+            return _changed == null ||
                    _changed.GetInvocationList().Length == 0;
         }
 
@@ -52,7 +52,7 @@ private bool ChangedIsEmpty()
         /// to call FireChanged.
         /// </summary>
         protected virtual void Enable()
-        {            
+        {
         }
 
         /// <summary>
@@ -60,7 +60,7 @@ protected virtual void Enable()
         /// when no cache dependencies remain listenning to the signal.
         /// </summary>
         protected virtual void Disable()
-        {            
+        {
         }
 
         /// <summary>
@@ -69,7 +69,7 @@ protected virtual void Disable()
         /// </summary>
         public void FireChanged()
         {
-            if (_changed != null) 
+            if (_changed != null)
                 _changed(this, EventArgs.Empty);
         }
     }
diff --git a/src/Spark.Web/Caching/CacheMemento.cs b/src/Spark/Caching/CacheMemento.cs
similarity index 60%
rename from src/Spark.Web/Caching/CacheMemento.cs
rename to src/Spark/Caching/CacheMemento.cs
index 18c92a74..4e475e0f 100644
--- a/src/Spark.Web/Caching/CacheMemento.cs
+++ b/src/Spark/Caching/CacheMemento.cs
@@ -1,4 +1,3 @@
-using System;
 using System.Collections.Generic;
 using Spark.Spool;
 
@@ -6,14 +5,10 @@ namespace Spark.Caching
 {
     public class CacheMemento
     {
-        public CacheMemento()
-        {
-            Content = new Dictionary<string, TextWriterMemento>();
-            OnceTable = new Dictionary<string, string>();
-        }
-
         public SpoolWriter SpoolOutput { get; set; }
-        public Dictionary<string, TextWriterMemento> Content { get; set;}
-        public Dictionary<string, string> OnceTable { get; set; }
+
+        public Dictionary<string, TextWriterMemento> Content { get; set; } = new();
+
+        public Dictionary<string, string> OnceTable { get; set; } = new();
     }
 }
diff --git a/src/Spark.Web/Caching/CacheOriginator.cs b/src/Spark/Caching/CacheOriginator.cs
similarity index 71%
rename from src/Spark.Web/Caching/CacheOriginator.cs
rename to src/Spark/Caching/CacheOriginator.cs
index c0b1511d..93aa3d26 100644
--- a/src/Spark.Web/Caching/CacheOriginator.cs
+++ b/src/Spark/Caching/CacheOriginator.cs
@@ -5,42 +5,35 @@
 
 namespace Spark.Caching
 {
-    public class CacheOriginator
+    public class CacheOriginator(SparkViewContext state)
     {
-        private readonly SparkViewContext _state;
-
         private TextWriter _priorOutput;
         private SpoolWriter _spoolOutput;
 
-        private readonly Dictionary<string, TextWriterOriginator> _priorContent = new Dictionary<string, TextWriterOriginator>();
+        private readonly Dictionary<string, TextWriterOriginator> _priorContent = new();
         private Dictionary<string, string> _priorOnceTable;
 
-        public CacheOriginator(SparkViewContext state)
-        {
-            _state = state;
-        }
-
         /// <summary>
         /// Establishes original state for memento capturing purposes
         /// </summary>
         public void BeginMemento()
         {
-            foreach (var content in _state.Content)
+            foreach (var content in state.Content)
             {
                 var writerOriginator = TextWriterOriginator.Create(content.Value);
                 _priorContent.Add(content.Key, writerOriginator);
                 writerOriginator.BeginMemento();
             }
 
-            _priorOnceTable = _state.OnceTable.ToDictionary(kv=>kv.Key, kv=>kv.Value);
+            _priorOnceTable = state.OnceTable.ToDictionary(kv=>kv.Key, kv=>kv.Value);
 
             // capture current output also if it's not locked into a named output at the moment
             // this could be a case in view's output, direct to network, or various macro or content captures
-            if (_state.Content.Any(kv => ReferenceEquals(kv.Value, _state.Output)) == false)
+            if (state.Content.Any(kv => ReferenceEquals(kv.Value, state.Output)) == false)
             {
-                _priorOutput = _state.Output;
+                _priorOutput = state.Output;
                 _spoolOutput = new SpoolWriter();
-                _state.Output = _spoolOutput;
+                state.Output = _spoolOutput;
             }
         }
 
@@ -57,7 +50,7 @@ public CacheMemento EndMemento()
             if (_priorOutput != null)
             {
                 _spoolOutput.WriteTo(_priorOutput);
-                _state.Output = _priorOutput;
+                state.Output = _priorOutput;
                 memento.SpoolOutput = _spoolOutput;
             }
             
@@ -69,15 +62,15 @@ public CacheMemento EndMemento()
                     memento.Content.Add(content.Key, textMemento);
             }
 
-            // also save any named content in it's entirety that added created after BeginMemento was called
-            foreach (var content in _state.Content.Where(kv => _priorContent.ContainsKey(kv.Key) == false))
+            // also save any named content in its entirety that added created after BeginMemento was called
+            foreach (var content in state.Content.Where(kv => _priorContent.ContainsKey(kv.Key) == false))
             {
                 var originator = TextWriterOriginator.Create(content.Value);
                 memento.Content.Add(content.Key, originator.CreateMemento());
             }
 
             // capture anything from the oncetable that was added after BeginMemento was called
-            var newItems = _state.OnceTable.Where(once => _priorOnceTable.ContainsKey(once.Key) == false);
+            var newItems = state.OnceTable.Where(once => _priorOnceTable.ContainsKey(once.Key) == false);
             memento.OnceTable = newItems.ToDictionary(once => once.Key, once => once.Value);
             return memento;
         }
@@ -88,16 +81,16 @@ public CacheMemento EndMemento()
         /// <param name="memento">memento captured in previous begin/end calls</param>
         public void DoMemento(CacheMemento memento)
         {
-            memento.SpoolOutput.WriteTo(_state.Output);
+            memento.SpoolOutput.WriteTo(state.Output);
 
             foreach (var content in memento.Content)
             {
                 // create named content if it doesn't exist
                 TextWriter writer;
-                if (_state.Content.TryGetValue(content.Key, out writer) == false)
+                if (state.Content.TryGetValue(content.Key, out writer) == false)
                 {
                     writer = new SpoolWriter();
-                    _state.Content.Add(content.Key, writer);
+                    state.Content.Add(content.Key, writer);
                 }
 
                 // and in any case apply the delta
@@ -106,10 +99,10 @@ public void DoMemento(CacheMemento memento)
             }
 
             // add recorded once deltas that were not yet in this subject's table
-            var newItems = memento.OnceTable.Where(once => _state.OnceTable.ContainsKey(once.Key) == false);
+            var newItems = memento.OnceTable.Where(once => state.OnceTable.ContainsKey(once.Key) == false);
             foreach (var once in newItems)
             {
-                _state.OnceTable.Add(once.Key, once.Value);
+                state.OnceTable.Add(once.Key, once.Value);
             }
         }
     }
diff --git a/src/Spark/Caching/SpoolWriterOriginator.cs b/src/Spark/Caching/SpoolWriterOriginator.cs
new file mode 100644
index 00000000..dd044591
--- /dev/null
+++ b/src/Spark/Caching/SpoolWriterOriginator.cs
@@ -0,0 +1,31 @@
+using System.Linq;
+using Spark.Spool;
+
+namespace Spark.Caching
+{
+    public class SpoolWriterOriginator(SpoolWriter writer) : TextWriterOriginator
+    {
+        private int _priorStringCount;
+
+        public override TextWriterMemento CreateMemento()
+        {
+            return new TextWriterMemento { Written = writer.ToArray() };
+        }
+
+        public override void BeginMemento()
+        {
+            _priorStringCount = writer.Count();
+        }
+
+        public override TextWriterMemento EndMemento()
+        {
+            return new TextWriterMemento { Written = writer.Skip(_priorStringCount).ToArray() };
+        }
+
+        public override void DoMemento(TextWriterMemento memento)
+        {
+            foreach (var written in memento.Written)
+                writer.Write(written);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Spark/Caching/StringWriterOriginator.cs b/src/Spark/Caching/StringWriterOriginator.cs
new file mode 100644
index 00000000..619a7ace
--- /dev/null
+++ b/src/Spark/Caching/StringWriterOriginator.cs
@@ -0,0 +1,32 @@
+using System.IO;
+
+namespace Spark.Caching
+{
+    public class StringWriterOriginator(StringWriter writer) : TextWriterOriginator
+    {
+        private int _priorLength;
+
+        public override TextWriterMemento CreateMemento()
+        {
+            return new TextWriterMemento {Written = new[] {writer.ToString()}};
+        }
+
+        public override void BeginMemento()
+        {
+            _priorLength = writer.GetStringBuilder().Length;
+        }
+
+        public override TextWriterMemento EndMemento()
+        {
+            var currentLength = writer.GetStringBuilder().Length;
+            var written = writer.GetStringBuilder().ToString(_priorLength, currentLength - _priorLength);
+            return new TextWriterMemento { Written = new[] { written } };
+        }
+
+        public override void DoMemento(TextWriterMemento memento)
+        {
+            foreach(var written in memento.Written)
+                writer.Write(written);
+        }
+    }
+}
diff --git a/src/Spark.Web/Caching/TextWriterOriginator.cs b/src/Spark/Caching/TextWriterOriginator.cs
similarity index 94%
rename from src/Spark.Web/Caching/TextWriterOriginator.cs
rename to src/Spark/Caching/TextWriterOriginator.cs
index b9885705..9727df17 100644
--- a/src/Spark.Web/Caching/TextWriterOriginator.cs
+++ b/src/Spark/Caching/TextWriterOriginator.cs
@@ -10,9 +10,15 @@ public abstract class TextWriterOriginator
         public static TextWriterOriginator Create(TextWriter writer)
         {
             if (writer is SpoolWriter)
+            {
                 return new SpoolWriterOriginator((SpoolWriter) writer);
+            }
+
             if (writer is StringWriter)
+            {
                 return new StringWriterOriginator((StringWriter)writer);
+            }
+
             throw new InvalidCastException("writer is unknown type " + writer.GetType().FullName);
         }
 
diff --git a/src/Spark/Compiler/CSharp/ChunkVisitors/GeneratedCodeVisitor.cs b/src/Spark/Compiler/CSharp/ChunkVisitors/GeneratedCodeVisitor.cs
index bff1a342..207a1641 100644
--- a/src/Spark/Compiler/CSharp/ChunkVisitors/GeneratedCodeVisitor.cs
+++ b/src/Spark/Compiler/CSharp/ChunkVisitors/GeneratedCodeVisitor.cs
@@ -503,6 +503,7 @@ protected override void Visit(CacheChunk chunk)
                 .RemoveIndent().WriteLine("}")
                 .RemoveIndent().WriteLine("}");
         }
+
         protected override void Visit(MarkdownChunk chunk)
         {
             CodeIndent(chunk).WriteLine("using(MarkdownOutputScope())");
diff --git a/src/Spark/Compiler/ChunkVisitors/DetectCodeExpressionVisitor.cs b/src/Spark/Compiler/ChunkVisitors/DetectCodeExpressionVisitor.cs
index 95307a8d..3a00578a 100644
--- a/src/Spark/Compiler/ChunkVisitors/DetectCodeExpressionVisitor.cs
+++ b/src/Spark/Compiler/ChunkVisitors/DetectCodeExpressionVisitor.cs
@@ -12,11 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 // 
-using System;
+
 using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using Spark.Compiler.ChunkVisitors;
 using Spark.Parser.Code;
 
 namespace Spark.Compiler.ChunkVisitors
@@ -62,7 +59,7 @@ void Examine(Snippets code)
 
         protected override void Visit(UseImportChunk chunk)
         {
-            
+            //no-op
         }
 
         protected override void Visit(ContentSetChunk chunk)
diff --git a/src/Spark.Web/ICacheService.cs b/src/Spark/ICacheService.cs
similarity index 93%
rename from src/Spark.Web/ICacheService.cs
rename to src/Spark/ICacheService.cs
index e4937080..2c9816aa 100644
--- a/src/Spark.Web/ICacheService.cs
+++ b/src/Spark/ICacheService.cs
@@ -1,5 +1,3 @@
-using System;
-
 namespace Spark
 {
     public interface ICacheService
diff --git a/src/Spark/InMemoryCacheService.cs b/src/Spark/InMemoryCacheService.cs
new file mode 100644
index 00000000..b60c8d9e
--- /dev/null
+++ b/src/Spark/InMemoryCacheService.cs
@@ -0,0 +1,71 @@
+using System;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Primitives;
+
+namespace Spark;
+
+public class InMemoryCacheService(IMemoryCache cache) : ICacheService
+{
+    public object Get(string identifier)
+    {
+        return cache.Get(identifier);
+    }
+
+    public void Store(string identifier, CacheExpires expires, ICacheSignal signal, object item)
+    {
+        var option = new MemoryCacheEntryOptions();
+
+        if (expires != null)
+        {
+            if (expires.Sliding > CacheExpires.NoSlidingExpiration)
+            {
+                option.SlidingExpiration = expires.Sliding;
+            }
+            else
+            {
+                option.AbsoluteExpiration = expires.Absolute;
+            }
+        }
+
+        if (signal != null)
+        {
+            option.AddExpirationToken(new SignalChangeToken(signal));
+        }
+
+        cache.Set(identifier, item, option);
+    }
+
+    private class SignalChangeToken : IChangeToken
+    {
+        private readonly ICacheSignal signal;
+        private bool hasChanged;
+        
+        public SignalChangeToken(ICacheSignal signal)
+        {
+            this.signal = signal;
+            this.signal.Changed += this.SignalOnChanged;
+        }
+
+        private void SignalOnChanged(object sender, EventArgs e)
+        {
+            this.hasChanged = true;
+        }
+
+        public bool HasChanged => this.hasChanged;
+
+        public bool ActiveChangeCallbacks => true;
+
+        public IDisposable RegisterChangeCallback(Action<object> callback, object state)
+        {
+            return new StopListeningToSignal(this);
+        }
+
+        private class StopListeningToSignal(SignalChangeToken signalChangeToken) : IDisposable
+        {
+            public void Dispose()
+            {
+                signalChangeToken.signal.Changed -= signalChangeToken.SignalOnChanged;
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Spark/Spark.csproj b/src/Spark/Spark.csproj
index 81e988fe..f060c4e1 100644
--- a/src/Spark/Spark.csproj
+++ b/src/Spark/Spark.csproj
@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
   <PropertyGroup>
     <OutputType>Library</OutputType>
     <TargetFrameworks>net48;net8.0</TargetFrameworks>
@@ -25,9 +25,11 @@
     <Description>Spark is a view engine allowing the HTML to dominate the flow and any code to fit seamlessly.</Description>
   </PropertyGroup>
   <ItemGroup>
-    <PackageReference Include="System.CodeDom" Version="8.0.0" Condition="'${TargetFramework}' == 'net48'" />
+    <PackageReference Include="MarkdownSharp" Version="2.0.5" />
     <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
     <PackageReference Include="Microsoft.CodeAnalysis.VisualBasic" Version="4.8.0" />
+    <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
+    <PackageReference Include="System.CodeDom" Version="8.0.0" Condition="'${TargetFramework}' == 'net48'" />
     <PackageReference Include="System.Configuration.ConfigurationManager" Version="8.0.0" />
   </ItemGroup>
   <ItemGroup>
diff --git a/src/Spark.Web/SparkViewBase.cs b/src/Spark/SparkViewBase.cs
similarity index 100%
rename from src/Spark.Web/SparkViewBase.cs
rename to src/Spark/SparkViewBase.cs
diff --git a/src/Spark.Web/SparkViewDecorator.cs b/src/Spark/SparkViewDecorator.cs
similarity index 100%
rename from src/Spark.Web/SparkViewDecorator.cs
rename to src/Spark/SparkViewDecorator.cs