# Why the Sitefinity page editor jumps when you drag a widget
The MVC page editor ships with a pile of drag and drop defects:
- The whole page shifts the moment you pick a widget up, so your drop target moves out from under the cursor
- Row, column and container all highlight at once, and nothing tells you which one takes the drop
- The highlight strobes on and off as you cross zone boundaries
- The thing following your pointer is a pale bar the width of the page instead of something widget shaped
- Outlines stay on the canvas after the drop until you reload
- Spacing in the editor doesn't match what publishes
None of it is your template's fault. It's the default behavior of the Telerik RadDock controls the editor is built on, controls that have not been meaningfully touched since MVC widgets shipped.
All of it is fixable. Most from your own theme stylesheet, the rest from around 250 lines of javascript, because the strobing is a *timing* problem and a stylesheet can't put a delay on a class it doesn't own. What you end up with: the page holds still, zones fade in and out instead of strobing, the drag helper is a small chip under your cursor, outlines clear themselves, and editor spacing matches published spacing.
Everything below assumes a plain MVC Sitefinity site on a stock Bootstrap 5 resource package. No custom widget framework, nothing exotic in the project. If you've got a Bootstrap 5 package and some `.row` and `.col-*` layouts, this applies to you.
## The page editor is not an iframe
This trips up most people, and it matters a lot.
Your page gets composed directly into the editor document. Which means two things at once. Your theme stylesheet is already loaded and already applying, so any CSS you write is live in the editor with no extra plumbing. And Sitefinity's editor markup is sitting in the same DOM as your markup, wrapping it.
That second part is where nearly every problem below comes from. Telerik wraps every layout and every widget in extra nesting that has no counterpart on the published page:
```html
DocumentServiceConfig.configView on GitHub <?xml version="1.0" encoding="utf-8"?>
<!--
App_Data/Sitefinity/Configuration/DocumentServiceConfig.config
Sitefinity MERGES these with its built-in registrations rather than replacing them, so listing
only what you are adding or overriding is enough. Out of the box it registers exactly five mime
types (pdf, docx, html, plain text, rtf); everything else indexes title-only.
The type string is "Namespace.ClassName, AssemblyName" with no version or public key token.
Replace "YourAssembly" with the assembly your extractors are compiled into.
Two things that catch people out:
1. Registering an extractor does NOTHING to documents already in the index. Deploy the
assembly, add this config, then run a full reindex from
Administration > Search indexes > (your index) > Reindex.
2. There is one registration per mime type, not per extractor. PresentationDocument opens
pptx, pptm and ppsx identically, but each mime type still needs its own line, and
Sitefinity constructs a separate instance for each and tells it which mime it is via
Initialize(mimeType, config).
-->
<documentServiceConfig>
<extractorSettings>
<!-- Overrides Sitefinity's DefaultPdfTextExtractor, which cannot open PDFs whose structure
tree throws during import. Same mime type, so this replaces the built-in entry. -->
<add mimeType="application/pdf"
extractorType="Sitefinity.TextExtractors.PdfTextExtractor, YourAssembly" />
<!-- PowerPoint: pptx, pptm, ppsx. No built-in extractor exists for any of these. -->
<add mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation"
extractorType="Sitefinity.TextExtractors.PptxTextExtractor, YourAssembly" />
<add mimeType="application/vnd.ms-powerpoint.presentation.macroEnabled.12"
extractorType="Sitefinity.TextExtractors.PptxTextExtractor, YourAssembly" />
<add mimeType="application/vnd.openxmlformats-officedocument.presentationml.slideshow"
extractorType="Sitefinity.TextExtractors.PptxTextExtractor, YourAssembly" />
<!-- Excel: xlsx, xlsm, xltx. No built-in extractor exists for any of these. -->
<add mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
extractorType="Sitefinity.TextExtractors.XlsxTextExtractor, YourAssembly" />
<add mimeType="application/vnd.ms-excel.sheet.macroEnabled.12"
extractorType="Sitefinity.TextExtractors.XlsxTextExtractor, YourAssembly" />
<add mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.template"
extractorType="Sitefinity.TextExtractors.XlsxTextExtractor, YourAssembly" />
<!-- Macro-enabled Word only. Plain .docx
(application/vnd.openxmlformats-officedocument.wordprocessingml.document) already has a
working built-in extractor: leave it alone. -->
<add mimeType="application/vnd.ms-word.document.macroEnabled.12"
extractorType="Sitefinity.TextExtractors.WordTextExtractor, YourAssembly" />
<!--
NOT registered, on purpose: the legacy binary formats.
.ppt application/vnd.ms-powerpoint
.doc application/msword
.xls application/vnd.ms-excel
These are OLE2 compound files, not ZIP packages, so the OpenXML SDK cannot read them and
pointing these extractors at them only produces errors. If you need them indexed, NPOI
reads all three and would need its own ITextExtractor implementation.
-->
</extractorSettings>
</documentServiceConfig>
Worth knowing before you go hunting for it: this merges, it does not replace. Adding entries here leaves the stock DOCX, HTML, plain and RTF registrations intact. A MIME type you name explicitly gets overridden by yours, everything else stays as shipped. I checked that against a live instance rather than trusting the docs, and it behaves.
You need one registration per MIME type even when a single class handles several, which is why PPTX, PPTM and PPSX all point at the same extractor. `Initialize` is where each instance learns which MIME type it got created for.
The thing that catches people out: registering an extractor does NOTHING to documents already in the index. Sitefinity extracts text at index time, not at search time, so existing documents keep whatever body text they had (usually none) until you rebuild the index from the search settings screen. Deploy the DLL, add the config, then reindex.
## A shared helper for writing the text back out
Every extractor ends the same way, turning a `StringBuilder` into UTF-8 bytes on the output stream. There's a trap in doing that the obvious way.
TextExtractorOutput.csView on GitHub using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace Sitefinity.TextExtractors
{
internal static class TextExtractorOutput
{
/// <summary>
/// Diagnostic context for a failed extraction.
///
/// signature vs mimeType is the whole diagnosis when a document will not open, because
/// Sitefinity derives the mime from the file extension and never from the bytes. A
/// document declared as xlsx whose signature reads "ole2" is a password-protected or
/// legacy binary file, and no amount of code will make the OpenXML SDK read it.
///
/// streamLength is the other half: compare it against the size your storage claims for
/// the document. Equal means the stored file really is damaged. Smaller means something
/// in your read path is truncating, which is a genuine bug worth chasing.
/// </summary>
internal static Dictionary<string, string> BuildContext(string mimeType, Stream doc)
{
var context = new Dictionary<string, string>
{
{ "mimeType", mimeType ?? "null" },
{ "signature", FileSignature.Describe(doc) }
};
// Length throws on some stream implementations; a partial context beats losing it all
try
{
context["streamLength"] = doc?.Length.ToString() ?? "null";
}
catch (Exception ex)
{
context["streamLength"] = ex.GetType().Name;
}
// The extractor only ever sees a stream, never the document it came from. If you
// want the title and id in your reports, stash them in an AsyncLocal from your
// inbound pipe before extraction and merge them in here. Without that you are
// correlating error reports to documents by timestamp, which is miserable.
return context;
}
internal static void WriteUtf8(StringBuilder builder, Stream text)
{
// Raw byte write rather than a StreamWriter: disposing a writer would close the
// caller's output stream before Sitefinity's DocumentService reads it back, and the
// resulting "cannot access a closed stream" is a confusing way to learn that.
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
text.Write(bytes, 0, bytes.Length);
}
}
}
Wrap the output in `using (var writer = new StreamWriter(text))` and you close the stream you were handed. Sitefinity then reads back from a closed stream, you get an empty body, and there's nothing obvious pointing at the cause. Write the bytes directly and leave the stream's lifetime to whoever created it.
## Fixing PDFs that index with no text
The whole point here is attaching the tolerant handler to `ImportSettings` so it's live *during* import, then skipping the structure tree entirely. Text extraction never needs the structure tree (it's an accessibility and reading-order artifact) so `IgnoreMarkedContent` costs you nothing and removes the most common failure surface in one line.
PdfTextExtractor.csView on GitHub using System;
using System.Collections.Specialized;
using System.IO;
using System.Text;
using Telerik.Sitefinity.Services.Documents;
using Telerik.Windows.Documents.Fixed.FormatProviders;
using Telerik.Windows.Documents.Fixed.FormatProviders.Pdf;
using Telerik.Windows.Documents.Fixed.FormatProviders.Text;
using Telerik.Windows.Documents.Fixed.Model;
namespace Sitefinity.TextExtractors
{
/// <summary>
/// Replaces Sitefinity's DefaultPdfTextExtractor.
///
/// THE BUG IN THE STOCK ONE: it attaches its exception-tolerant handler to the document
/// AFTER Import() returns, which is too late for the failures that matter. Structure-tree
/// problems (InvalidStructureTreeException) and RichMedia annotations throw DURING import,
/// so the handler is never reached and the whole document indexes with no body text. On an
/// older library that is hundreds of files.
///
/// Two changes fix it:
/// - subscribe DocumentUnhandledException on ImportSettings, so it is live during import
/// - set IgnoreMarkedContent, which skips the structure tree altogether
///
/// The structure tree is accessibility and reading-order metadata. Text extraction never
/// needs it, so skipping it costs nothing and removes an entire class of failure.
///
/// Uses Telerik Document Processing, which already ships with Sitefinity.
/// </summary>
public class PdfTextExtractor : ITextExtractor
{
public string MimeType { get; private set; }
public void Initialize(string mimeType, NameValueCollection config)
{
this.MimeType = mimeType;
}
public void GetText(Stream doc, Stream text)
{
ExtractorGuard.Run(nameof(PdfTextExtractor), "ExtractText", () => TextExtractorOutput.BuildContext(this.MimeType, doc), () =>
{
this.Extract(doc, text);
});
}
private void Extract(Stream doc, Stream text)
{
var provider = new PdfFormatProvider();
// OnDemand parses pages lazily instead of materialising the whole document up front,
// which matters when a reindex walks thousands of files
provider.ImportSettings.ReadingMode = ReadingMode.OnDemand;
provider.ImportSettings.IgnoreMarkedContent = true;
// The line the stock extractor gets wrong. Subscribing HERE, on the settings rather
// than the document, is what makes it active while Import is running.
provider.ImportSettings.DocumentUnhandledException += (sender, e) =>
{
e.Handled = true;
};
// Mirrors the stock extractor's use of the documentServiceConfig timeout (minutes)
var timeout = TimeSpan.FromMinutes(5);
RadFixedDocument document = provider.Import(doc, timeout);
if (document == null)
{
return;
}
// And again on the document, for anything thrown during export rather than import
document.DocumentUnhandledException += (sender, e) =>
{
e.Handled = true;
};
var exporter = new TextFormatProvider();
var settings = new TextFormatProviderSettings("\r\n", string.Empty);
var extracted = exporter.Export(document, settings, timeout);
// A scanned PDF is a picture of text with no text layer, so extraction "succeeds"
// with an empty string and the document indexes title-only with no error anywhere.
// This is the seam where OCR would go. The branch is live but the call is not, so
// measuring how many of your documents are scans is a one-line change.
var looksLikeAScan = document.Pages.Count > 0
&& extracted.Trim().Length < document.Pages.Count * 20;
if (looksLikeAScan)
{
// extracted = OcrPages(document);
}
TextExtractorOutput.WriteUtf8(new StringBuilder(extracted), text);
}
// NOT WIRED UP. Sketch for adding OCR against an external service (Azure AI Document
// Intelligence, AWS Textract, a Tesseract sidecar; all take an image and return text).
// Read these three before enabling it:
//
// 1. Billed per page, and a full reindex reprocesses every document. Cache results
// keyed by document id or every rebuild costs real money.
// 2. Seconds per page, on the indexing thread. A few hundred scans turns a reindex
// from minutes into hours. If this becomes real, the right shape is a background
// job writing into an indexed field, not inline extraction.
// 3. OCR returns confidently wrong words on bad scans, and those become real search
// terms. Usually still better than an empty document, but it is not the same
// quality bar as a genuine text layer, and users cannot tell the difference.
//
// private string OcrPages(RadFixedDocument document)
// {
// var builder = new StringBuilder();
// foreach (var page in document.Pages)
// {
// // Telerik can rasterise a page for you; the provider type has moved between
// // versions (Skia-based currently), so check what your assemblies ship.
// byte[] pageImage;
// using (var buffer = new MemoryStream())
// {
// // imageProvider.Export(page, buffer);
// pageImage = buffer.ToArray();
// }
//
// // Keep the per-page timeout short; one stalled call holds the whole reindex.
// // builder.AppendLine(ocrClient.Recognize(pageImage));
// }
// return builder.ToString();
// }
}
}
Couple of notes if you're copying this. `ReadingMode` lives in `Telerik.Windows.Documents.Fixed.FormatProviders`, NOT the `.Pdf` namespace you'd expect, which is an easy twenty minutes lost to `CS0103`. And `ReadingMode.OnDemand` keeps page content lazy, which matters when the indexer is chewing through hundreds of files and you'd rather not hold every page of every PDF in memory at once.
This does not turn every broken PDF into clean text. What it fixes is the population of structurally damaged but genuinely textual PDFs that the stock extractor throws away wholesale. A PDF that's purely scanned images is a different case, and the next bit covers where that would hook in.
### Where OCR would go, if you have a service for it
Some PDFs have no text layer at all. Somebody scanned a paper handout, or printed to PDF from an image, and every page is a picture of words. There's nothing for `TextFormatProvider` to export, so extraction "succeeds" and returns an empty string. In the index that's indistinguishable from a file that failed.
You can detect it cheaply though. If a PDF has pages but almost no extracted characters, it's an image-only document. That check is already in `PdfTextExtractor.cs` above, near the bottom. The branch is live, the `OcrPages` call inside it is commented out, so logging that branch tells you how big your scan problem actually is before you spend a penny on it.
Before you wire that up though, none of these are code problems.
It costs money per page, and a reindex processes every document again. A library with a few thousand scanned pages can turn one "rebuild the index" click into a real invoice. If you do this, cache the OCR result somewhere keyed by the document so a second reindex reads the cache instead of re-billing you.
It's slow. OCR is seconds per page against milliseconds for normal extraction, and it runs inline on the indexing thread. A few hundred scanned documents can stretch a reindex from minutes into hours. Doing the OCR in a separate background job and writing the result into a field the indexer reads is the saner architecture, it's just a bigger build.
And it's fallible in a way normal extraction isn't. OCR on a bad scan produces plausible-looking wrong words, which then land in your search index as real terms. Usually still better than an empty document, but "searchable" and "accurate" have come apart at that point.
I left this as a hook rather than an implementation. The image-only PDFs in the library were a small enough slice that the cost and complexity weren't worth it yet, and the detection line above at least tells you how big that slice actually is if you log it.
## Indexing PowerPoint slides, including speaker notes
No Telerik dependency needed here. The OpenXML SDK (`DocumentFormat.OpenXml`) already ships with Sitefinity so this is free. PPTX, PPTM and PPSX are all ZIP packages of XML, and `PresentationDocument.Open` reads all three.
The useful trick is that all visible slide text lives in `a:t` elements no matter how deeply shapes, groups, tables and text boxes are nested. Walking `Descendants()` gets you everything without modeling the shape tree at all.
PptxTextExtractor.csView on GitHub using System.Collections.Specialized;
using System.IO;
using System.Text;
using DocumentFormat.OpenXml.Packaging;
using Telerik.Sitefinity.Services.Documents;
namespace Sitefinity.TextExtractors
{
/// <summary>
/// Sitefinity ships no PowerPoint extractor at all, so pptx / pptm / ppsx documents index
/// with a title and no body text. Uses the OpenXML SDK that already ships with Sitefinity,
/// so there is no new dependency.
///
/// Register one entry per mime type in DocumentServiceConfig.config; see registration.config.
/// </summary>
public class PptxTextExtractor : ITextExtractor
{
public string MimeType { get; private set; }
public void Initialize(string mimeType, NameValueCollection config)
{
// Sitefinity constructs one instance per registered mime type and tells it which
// one it is. PresentationDocument opens all three formats identically.
this.MimeType = mimeType;
}
public void GetText(Stream doc, Stream text)
{
// A failure costs this document's body text only, never the indexed item
ExtractorGuard.Run(nameof(PptxTextExtractor), "ExtractText", () => TextExtractorOutput.BuildContext(this.MimeType, doc), () =>
{
OpenXmlPackageReader.Read(doc, package => this.ExtractCore(package, text));
});
}
/// <summary>
/// Called by OpenXmlPackageReader, possibly twice: once on the original stream and, if
/// the SDK refuses that, once on a repaired copy. Must therefore be safe to run twice.
/// </summary>
private void ExtractCore(Stream doc, Stream text)
{
var builder = new StringBuilder();
// Deliberately not a using block. The SDK's close-time cleanup throws on a package
// opened read-only, and that would discard the text collected just below it, so
// disposal goes through DisposeQuietly instead.
var presentation = PresentationDocument.Open(doc, false);
try
{
var presentationPart = presentation.PresentationPart;
if (presentationPart == null)
{
return;
}
foreach (var slidePart in presentationPart.SlideParts)
{
// Every piece of visible slide text is an a:t element, no matter how deeply
// it is nested in shapes, groups, tables or text boxes. Walking descendants
// gets all of it without modelling the shape tree at all.
foreach (var textNode in slidePart.Slide.Descendants<DocumentFormat.OpenXml.Drawing.Text>())
{
builder.AppendLine(textNode.Text);
}
// Speaker notes are often the most searchable text in the whole deck: the
// slide says "Management" over a diagram while the notes pane holds the
// actual prose somebody will search for months later.
var notes = slidePart.NotesSlidePart;
if (notes != null)
{
foreach (var noteText in notes.NotesSlide.Descendants<DocumentFormat.OpenXml.Drawing.Text>())
{
builder.AppendLine(noteText.Text);
}
}
}
}
finally
{
OpenXmlPackageReader.DisposeQuietly(presentation);
}
TextExtractorOutput.WriteUtf8(builder, text);
}
}
}
Speaker notes deserve their own mention. A slide will often read "Management" over a diagram while the notes pane holds the actual prose, the drug names and the caveats and the sentence somebody is going to search for six months later. Indexing slides but skipping notes throws away the most searchable text in the file, and it's four extra lines to include it.
### The printer settings bug you'll hit in production
Run this against a real document library and within a day or two you'll see this:
```
DocumentFormat.OpenXml.Packaging.OpenXmlPackageException: The document cannot be
opened because there is an invalid part with an unexpected content type.
[Part Uri=/ppt/printerSettings/printerSettings1.bin],
[Content Type=application/vnd.openxmlformats-officedocument.presentationml.printerSettings],
[Expected Content Type=application/vnd.openxmlformats-officedocument.spreadsheetml.printerSettings].
```
Read the last two lines. The part's content type is correct. The SDK's *expectation* is wrong, it wants the spreadsheet printer-settings type inside a presentation. The OpenXML SDK build Sitefinity ships is 2.0.5022.0, the original 2008 release, and its content-type table maps every printer-settings part to the spreadsheet variant. So any deck saved by a copy of PowerPoint that recorded printer settings, which is most of them on an office-installed machine, refuses to open. Fixed in SDK 2.5+, but you can't swap that DLL out from under Sitefinity's own dependency.
Printer settings carry no indexable text, so catch the failure, strip those parts out of an in-memory copy with `System.IO.Packaging` (already there, it's in WindowsBase), and retry:
OpenXmlPackageReader.csView on GitHub using System;
using System.IO;
using DocumentFormat.OpenXml.Packaging;
namespace Sitefinity.TextExtractors
{
/// <summary>
/// Shared open policy for the three OOXML extractors (pptx, xlsx, docm).
///
/// Every workaround for the OpenXML SDK lives here rather than in the extractors, because
/// each extractor originally carried its own copy and the same gap then had to be found
/// three separate times in production.
///
/// The sequence is: reject input that is not a zip at all, try the straightforward open,
/// and on the two known SDK failures retry against a repaired copy of the package.
/// </summary>
internal static class OpenXmlPackageReader
{
internal static void Read(Stream doc, Action<Stream> readPackage)
{
// Not a zip, so there is no OOXML package inside it. See the FileSignature header
// for why a non-zip reaches an OOXML extractor in the first place.
if (!FileSignature.IsZip(doc))
{
return;
}
try
{
readPackage(doc);
}
catch (FileFormatException)
{
// Starts with the zip magic but has no central directory, so the upload is
// truncated or damaged. Unlike the SDK defects below there is nothing to strip
// and retry: a zip with no directory cannot be read by anything. Skip it the
// same way a non-zip is skipped, and let the document index on its title.
//
// This is a deliberate trade. Skipping silently means you can no longer see
// WHICH files are damaged. If you would rather find and re-upload them, delete
// this catch and let the guard report them (capped) instead.
return;
}
catch (Exception ex) when (IsRecoverableOpenFailure(ex))
{
// The SDK refused the package. The known cause is printer-settings parts, so
// retry against a copy with those removed.
Stream sanitized = null;
try
{
sanitized = OpenXmlPackageSanitizer.StripPrinterSettings(doc);
}
catch
{
// A package broken some other way makes the sanitizer throw on its own.
// Letting that escape would report the failed recovery instead of the
// actual fault, which is a much harder thing to diagnose later.
}
if (sanitized == null)
{
throw;
}
using (sanitized)
{
readPackage(sanitized);
}
}
}
/// <summary>
/// Both exception types mean the same thing here: the SDK could not load this package,
/// and a printerSettings-stripped copy is worth trying.
/// </summary>
private static bool IsRecoverableOpenFailure(Exception ex)
{
if (ex is OpenXmlPackageException)
{
return true;
}
// IOException looks unrelated and is not. When Load() fails, its own cleanup path
// calls Close() -> DeleteUnusedDataPartOnClose() -> Package.DeletePart(), and that
// throws IOException("Cannot modify a read-only container") because the package was
// opened read-only. The cleanup failure REPLACES the OpenXmlPackageException that
// caused it, so matching only on the latter means the retry below never runs and
// you are left staring at a read-only error that explains nothing.
return ex is IOException;
}
/// <summary>
/// Disposes a package opened read-only, tolerating the same SDK cleanup defect.
///
/// DeleteUnusedDataPartOnClose runs on EVERY dispose, not only failed loads, so a
/// document that opened and extracted perfectly can still throw on the closing brace of
/// a using block. Callers write their extracted text after disposing, so letting that
/// through would silently discard work that already succeeded.
/// </summary>
internal static void DisposeQuietly(OpenXmlPackage package)
{
if (package == null)
{
return;
}
try
{
package.Dispose();
}
catch (IOException)
{
// Safe precisely because the package is read-only: there are no pending writes
// to lose. The same swallow on a writable package would be a real bug.
}
}
}
}
where `ExtractCore` is the extraction body from above, and the sanitizer rewinds the source stream, copies it, and removes the parts plus every relationship pointing at them:
OpenXmlPackageSanitizer.csView on GitHub using System;
using System.IO;
using System.Linq;
namespace Sitefinity.TextExtractors
{
/// <summary>
/// Works around a defect in OpenXML SDK 2.0 (2.0.5022.0, the build Sitefinity ships).
///
/// The SDK's content-type expectation table maps EVERY printerSettings part to the
/// SPREADSHEET printer-settings content type. So a pptx or docm saved on a machine with a
/// printer configured, which is most of them, fails to open with:
///
/// The document cannot be opened because there is an invalid part with an unexpected
/// content type. [Part Uri=/ppt/printerSettings/printerSettings1.bin] ...
/// [Expected Content Type=...spreadsheetml.printerSettings]
///
/// The part is fine. The SDK's expectation is wrong. It is fixed in SDK 2.5+, but you
/// generally cannot upgrade that assembly out from under Sitefinity's own dependency.
///
/// Printer settings carry no indexable text, so the recovery is to hand back a copy of the
/// package with those parts removed and let the caller retry.
/// </summary>
internal static class OpenXmlPackageSanitizer
{
/// <summary>
/// Returns a seekable in-memory copy of the package with all printerSettings parts and
/// the relationships pointing at them removed. Returns null when there is nothing to
/// strip or the source cannot be re-read, in which case the caller should rethrow the
/// original open failure rather than pretend it recovered.
/// </summary>
internal static Stream StripPrinterSettings(Stream doc)
{
// The failed open already consumed part of the stream. Without seek there is
// nothing left to copy, so the caller keeps its original exception.
if (doc == null || !doc.CanSeek)
{
return null;
}
doc.Seek(0, SeekOrigin.Begin);
var working = new MemoryStream();
doc.CopyTo(working);
working.Seek(0, SeekOrigin.Begin);
// System.IO.Packaging lives in WindowsBase and is fully qualified throughout this
// file, because DocumentFormat.OpenXml.Packaging has colliding type names.
using (var package = System.IO.Packaging.Package.Open(working, FileMode.Open, FileAccess.ReadWrite))
{
var printerParts = package.GetParts()
.Where(part => part.Uri.OriginalString.IndexOf("printerSettings", StringComparison.OrdinalIgnoreCase) >= 0)
.Select(part => part.Uri)
.ToList();
if (printerParts.Count == 0)
{
return null;
}
// A relationship pointing at a part that no longer exists fails validation just
// like the bad part did, so the relationships go first.
foreach (var part in package.GetParts().ToList())
{
// Relationship parts cannot themselves carry relationships; asking throws.
if (part.Uri.OriginalString.EndsWith(".rels", StringComparison.OrdinalIgnoreCase))
{
continue;
}
foreach (var rel in part.GetRelationships().ToList())
{
if (!IsInternalTarget(rel))
{
continue;
}
var target = System.IO.Packaging.PackUriHelper.ResolvePartUri(part.Uri, rel.TargetUri);
if (printerParts.Contains(target))
{
part.DeleteRelationship(rel.Id);
}
}
}
foreach (var rel in package.GetRelationships().ToList())
{
if (!IsInternalTarget(rel))
{
continue;
}
// PackageRootUri is a .NET Core addition; on Framework, resolve against "/".
var target = System.IO.Packaging.PackUriHelper.ResolvePartUri(new Uri("/", UriKind.Relative), rel.TargetUri);
if (printerParts.Contains(target))
{
package.DeleteRelationship(rel.Id);
}
}
foreach (var uri in printerParts)
{
package.DeletePart(uri);
}
}
// Package.Close disposes the working stream. MemoryStream.ToArray still reads after
// dispose, so hand back a fresh stream over the finished bytes.
return new MemoryStream(working.ToArray());
}
/// <summary>
/// The single most important line in this file.
///
/// An EXTERNAL relationship, which is what an ordinary hyperlink is, carries an absolute
/// URI, and PackUriHelper.ResolvePartUri throws ArgumentException("Cannot be an absolute
/// URI") on those. Without this guard the sanitizer throws on any document containing a
/// link, the caller treats the package as unrecoverable, and the original error is
/// rethrown as though no recovery had been attempted.
///
/// This is easy to miss because a hand-built test package has no hyperlinks and passes.
/// Real documents nearly always have them.
/// </summary>
private static bool IsInternalTarget(System.IO.Packaging.PackageRelationship relationship)
{
return relationship.TargetMode == System.IO.Packaging.TargetMode.Internal
&& !relationship.TargetUri.IsAbsoluteUri;
}
}
}
Two things in there are not obvious, and I only found them because real files behave differently than test files.
`IsInternalTarget` exists because `PackUriHelper.ResolvePartUri` throws `ArgumentException("Cannot be an absolute URI")` the moment you hand it an external relationship, and an ordinary hyperlink IS an external relationship. My first sanitizer worked perfectly against a synthetic deck I built to test it, then failed on every single real document, because real documents have links in them.
`IsRecoverableOpenFailure` catching `IOException` looks wrong and isn't. When the SDK's `Load()` fails it runs its own cleanup, `Close()` to `DeleteUnusedDataPartOnClose()` to `Package.DeletePart()`, and that last call throws `IOException("Cannot modify a read-only container")` because the package was opened read-only. That second exception REPLACES the `OpenXmlPackageException` that caused it. So if you only catch the one you'd expect, your recovery never runs and you're left staring at a read-only error that explains nothing. Same cleanup also runs on successful disposal, which is what `DisposeQuietly` is for: without it a document that opened and extracted perfectly can still throw on the closing brace of the using block and take your extracted text with it.
Word and Excel ride the same defective content-type table, so all three OOXML extractors go through the same reader. The catch-and-retry shape means a clean document costs you nothing extra, the sanitizer only runs after the SDK has already refused the file.
### Not every file that claims to be a zip is one
Sitefinity picks the extractor from the document's stored MIME type, and that MIME type comes from the file EXTENSION at upload time. Never from the bytes. So an author renames a legacy `.xls` to `.xlsx` and it lands in the OOXML extractor without being a zip at all. More common than that: a password-protected Office file isn't a zip either, because the encryption wraps the whole OOXML package inside an OLE2 compound file.
Neither is readable by the OpenXML SDK, ever, and neither is a defect you can fix in code. Checking the magic number first means those skip quietly instead of generating error reports nobody can action:
FileSignature.csView on GitHub using System;
using System.IO;
namespace Sitefinity.TextExtractors
{
/// <summary>
/// Magic-number sniffing for the document text extractors.
///
/// Sitefinity picks an extractor from the document's stored mime type, which is derived from
/// the file EXTENSION at upload time and never from the bytes. Two consequences show up in
/// any real library:
///
/// - a legacy binary .xls renamed to .xlsx reaches the OOXML extractor and is not a zip
/// - far more commonly, a password-protected Office file is not a zip either, because
/// encryption wraps the entire OOXML package inside an OLE2 compound file
///
/// The OpenXML SDK can never read either, and its failure (FileFormatException, "File
/// contains corrupted data") is a property of the upload rather than a defect to fix. So the
/// extractors check the signature first and skip, instead of reporting an error nobody can
/// action.
/// </summary>
internal static class FileSignature
{
/// <summary>
/// True when the stream starts with the zip magic every OOXML package must begin with.
/// A stream that cannot be sniffed gets the benefit of the doubt: better to let the SDK
/// try and fail than to skip a file that was perfectly readable.
/// </summary>
internal static bool IsZip(Stream doc)
{
var header = ReadHeader(doc);
if (header == null)
{
return true;
}
return IsZip(header);
}
/// <summary>
/// Short label for error-report context, so a future failure arrives already diagnosed
/// instead of as a bare "corrupted data". Comparing this against the document's declared
/// mime type is usually the whole diagnosis. Never throws.
/// </summary>
internal static string Describe(Stream doc)
{
try
{
var header = ReadHeader(doc);
if (header == null)
{
return "unreadable";
}
if (header.Length == 0)
{
return "empty";
}
if (IsZip(header))
{
return "zip";
}
if (StartsWith(header, 0xD0, 0xCF, 0x11, 0xE0))
{
return "ole2 (legacy or password-protected office file)";
}
if (StartsWith(header, 0x25, 0x50, 0x44, 0x46))
{
return "pdf";
}
return BitConverter.ToString(header);
}
catch (Exception ex)
{
return ex.GetType().Name;
}
}
// "PK" covers the local-header, empty-archive and spanned-archive variants
private static bool IsZip(byte[] header)
{
return StartsWith(header, 0x50, 0x4B);
}
private static bool StartsWith(byte[] header, params byte[] magic)
{
if (header.Length < magic.Length)
{
return false;
}
for (var i = 0; i < magic.Length; i++)
{
if (header[i] != magic[i])
{
return false;
}
}
return true;
}
/// <summary>
/// Returns the first bytes of the package, or null when the stream cannot be sniffed
/// without consuming bytes the caller still needs. Always restores the original
/// position, because the SDK reads the same stream immediately afterwards.
/// </summary>
private static byte[] ReadHeader(Stream doc)
{
if (doc == null || !doc.CanRead || !doc.CanSeek)
{
return null;
}
var origin = doc.Position;
try
{
doc.Seek(0, SeekOrigin.Begin);
// Read can return short of the request even with bytes remaining, so loop
var buffer = new byte[8];
var filled = 0;
while (filled < buffer.Length)
{
var read = doc.Read(buffer, filled, buffer.Length - filled);
if (read <= 0)
{
break;
}
filled += read;
}
var header = new byte[filled];
Array.Copy(buffer, header, filled);
return header;
}
finally
{
doc.Seek(origin, SeekOrigin.Begin);
}
}
}
}
## Indexing Excel spreadsheets
Spreadsheets keep their strings in a shared-string table rather than inline, so a naive walk over cells hands you a pile of integer indices. You have to dereference them.
XlsxTextExtractor.csView on GitHub using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using System.Text;
using DocumentFormat.OpenXml.Packaging;
using Telerik.Sitefinity.Services.Documents;
namespace Sitefinity.TextExtractors
{
/// <summary>
/// Sitefinity ships no spreadsheet extractor, so xlsx / xlsm / xltx documents index with a
/// title and no body text.
///
/// Register one entry per mime type in DocumentServiceConfig.config; see registration.config.
/// </summary>
public class XlsxTextExtractor : ITextExtractor
{
public string MimeType { get; private set; }
public void Initialize(string mimeType, NameValueCollection config)
{
// SpreadsheetDocument opens xlsm and xltx the same way it opens xlsx
this.MimeType = mimeType;
}
public void GetText(Stream doc, Stream text)
{
ExtractorGuard.Run(nameof(XlsxTextExtractor), "ExtractText", () => TextExtractorOutput.BuildContext(this.MimeType, doc), () =>
{
OpenXmlPackageReader.Read(doc, package => this.ExtractCore(package, text));
});
}
private void ExtractCore(Stream doc, Stream text)
{
var builder = new StringBuilder();
// Not a using block: see DisposeQuietly in OpenXmlPackageReader
var spreadsheet = SpreadsheetDocument.Open(doc, false);
try
{
var workbookPart = spreadsheet.WorkbookPart;
if (workbookPart == null)
{
return;
}
// THE THING THAT SURPRISES PEOPLE ABOUT XLSX: cell text is not stored in the
// cell. Strings live once in a shared-string table and each cell holds an
// integer index into it, so a naive walk over cells hands you a pile of numbers
// and no words. Load the table first, then dereference.
var sharedStrings = new List<string>();
var sharedStringPart = workbookPart.SharedStringTablePart;
if (sharedStringPart != null)
{
sharedStrings = sharedStringPart.SharedStringTable
.Elements<DocumentFormat.OpenXml.Spreadsheet.SharedStringItem>()
.Select(item => item.InnerText)
.ToList();
}
foreach (var sheetPart in workbookPart.WorksheetParts)
{
foreach (var cell in sheetPart.Worksheet.Descendants<DocumentFormat.OpenXml.Spreadsheet.Cell>())
{
if (cell.CellValue == null)
{
continue;
}
var value = cell.CellValue.InnerText;
if (cell.DataType != null && cell.DataType.Value == DocumentFormat.OpenXml.Spreadsheet.CellValues.SharedString)
{
// Bounds-check rather than trust the index: a corrupt or truncated
// shared-string table would otherwise throw for the whole document
// when the cost of one bad cell should be one bad cell.
int index;
if (int.TryParse(value, out index) && index >= 0 && index < sharedStrings.Count)
{
builder.AppendLine(sharedStrings[index]);
}
}
else
{
// Inline strings, numbers and dates. Numbers are worth keeping:
// people search for order numbers and student ids.
builder.AppendLine(value);
}
}
}
}
finally
{
OpenXmlPackageReader.DisposeQuietly(spreadsheet);
}
TextExtractorOutput.WriteUtf8(builder, text);
}
}
}
That bounds check on the index isn't ceremony. A corrupt or hand-generated workbook can carry a shared-string index past the end of the table, and an unguarded lookup throws in the middle of indexing over a value nobody would have searched for anyway. Skip the cell, keep the document.
## Indexing macro-enabled Word files (.docm)
Smallest of the four and the most purely bureaucratic. A `.docm` file is DOCX with macros, the content is identical. It only misses out because of the MIME type mismatch from earlier.
WordTextExtractor.csView on GitHub using System.Collections.Specialized;
using System.IO;
using System.Text;
using DocumentFormat.OpenXml.Packaging;
using Telerik.Sitefinity.Services.Documents;
namespace Sitefinity.TextExtractors
{
/// <summary>
/// Covers macro-enabled Word (.docm).
///
/// Sitefinity's built-in extractor is registered against the docx mime type only, and docm
/// has a different one, so macro-enabled documents fall through to no extractor at all and
/// index title-only. The file format is otherwise identical, which is why this is short.
///
/// Plain .docx should stay with the built-in DefaultTextExtractor: do not register this
/// against that mime type, there is nothing to gain.
/// </summary>
public class WordTextExtractor : ITextExtractor
{
public string MimeType { get; private set; }
public void Initialize(string mimeType, NameValueCollection config)
{
this.MimeType = mimeType;
}
public void GetText(Stream doc, Stream text)
{
ExtractorGuard.Run(nameof(WordTextExtractor), "ExtractText", () => TextExtractorOutput.BuildContext(this.MimeType, doc), () =>
{
OpenXmlPackageReader.Read(doc, package => this.ExtractCore(package, text));
});
}
private void ExtractCore(Stream doc, Stream text)
{
var builder = new StringBuilder();
// Not a using block: see DisposeQuietly in OpenXmlPackageReader
var word = WordprocessingDocument.Open(doc, false);
try
{
var body = word.MainDocumentPart?.Document?.Body;
if (body == null)
{
return;
}
// Paragraph.InnerText concatenates every run inside the paragraph, which is what
// you want: Word splits a single sentence across runs whenever formatting or
// spell-check state changes mid-line, so reading runs individually would shred
// words into fragments that match nothing.
foreach (var paragraph in body.Descendants<DocumentFormat.OpenXml.Wordprocessing.Paragraph>())
{
builder.AppendLine(paragraph.InnerText);
}
// Note: this reads the document body only. Headers, footers and footnotes live
// in separate parts (word.MainDocumentPart.HeaderParts and friends) and are
// usually boilerplate, so they are skipped on purpose.
}
finally
{
OpenXmlPackageReader.DisposeQuietly(word);
}
TextExtractorOutput.WriteUtf8(builder, text);
}
}
}
Leave plain `.docx` with the built-in extractor. No reason to take ownership of a format Sitefinity already handles.
## Failure behavior matters more than the parsing
Sitefinity runs inbound publishing pipes through a helper that amounts to `try { action(item); } catch { Log.Error(...); }` with no rethrow. Which keeps one bad file from killing an entire reindex, fair enough. The cost is that an exception in the indexing path makes the document silently disappear from the index. No error page, no failed job, just a document that is no longer findable and a line in a log file nobody reads.
So the rule for extractor code is that a failure should cost you one document's body text. Never the document itself, and never the run.
Don't let an exception escape `GetText` if you can avoid it. Catch it, report it somewhere you actually look, and return with an empty output stream so the document still gets indexed by title and metadata.
Report to your error tracker rather than only the log file. I route extractor failures to Sentry with the MIME type and stream length attached. One caveat worth designing around: `GetText` only receives a stream, so the document ID lives in the caller and isn't available to you. If you need to identify the specific file, match on timestamp against the log.
And cap the reporting. A systemic problem during a full reindex fails once per document, and twenty thousand documents means twenty thousand identical events, which is exactly how a useful signal ends up muted as noise. I cap at ten per extractor per app domain and carry a counter in the payload so the real scale stays visible.
All three of those live in one place, so the extractors just wrap their work in it:
ExtractorGuard.csView on GitHub using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
namespace Sitefinity.TextExtractors
{
/// <summary>
/// Containment boundary for search-index extraction.
///
/// WHY THIS EXISTS, because it looks like ceremony around a try/catch:
/// Telerik runs every inbound pipe through PublishingHelper.ForEachSafe, which swallows
/// exceptions and silently DROPS the item from the Lucene index. An unguarded
/// NullReferenceException in a document pipe will quietly unindex documents for as long as
/// it takes somebody to notice they cannot find a file. Wrapping each step caps the blast
/// radius of a failure at one document's body text instead of the whole item, and reports it
/// somewhere a human will actually see.
///
/// THE CAP IS NOT OPTIONAL: a systemic data problem during a full reindex fails once per
/// item, so 20,000 documents means 20,000 identical error reports, which is the same as
/// having none. The first few carry the signal and the counter preserves the true scale.
/// </summary>
internal static class ExtractorGuard
{
/// <summary>
/// Wire this up once at startup to Sentry, Raygun, log4net, or whatever you use:
/// (exception, contextData, message). Left null, failures are contained silently.
/// </summary>
internal static Action<Exception, Dictionary<string, string>, string> Report;
private const int MaxReportsPerStep = 10;
private static readonly ConcurrentDictionary<string, int> ReportCounts = new ConcurrentDictionary<string, int>();
internal static void Run(string pipeName, string step, Func<Dictionary<string, string>> contextBuilder, Action action)
{
try
{
action();
}
catch (Exception ex)
{
var failureCount = ReportCounts.AddOrUpdate($"{pipeName}.{step}", 1, (key, count) => count + 1);
if (failureCount > MaxReportsPerStep)
{
return;
}
var customData = new Dictionary<string, string>
{
{ "pipe", pipeName },
{ "step", step },
{ "failureCountThisAppDomain", failureCount.ToString() },
{ "reportingCapped", (failureCount == MaxReportsPerStep).ToString() }
};
// Context readers touch lazy-loaded Sitefinity properties and may throw on a
// background thread. A catch block that throws is worse than the original bug,
// so a failure to build context must never mask the exception being reported.
try
{
if (contextBuilder != null)
{
foreach (var pair in contextBuilder())
{
customData[pair.Key] = pair.Value ?? "null";
}
}
}
catch (Exception contextEx)
{
customData["contextError"] = contextEx.Message;
}
var report = Report;
if (report != null)
{
report(ex, customData, $"{pipeName}.{step} failed; the item was indexed without this data");
}
}
}
}
}
That last one turned out to be the biggest quality-of-life win of the whole exercise. Before, a reindex dumped a sixty-plus-line Telerik stack trace into the error log for every failing PDF, hundreds of times over, burying anything else happening on the site. Same situation now produces ten events with a count attached, and a log you can still read.
## After deploying it
Formats that had no extractor at all now index their contents: slides plus speaker notes, spreadsheet cells, macro-enabled documents. The "MIME type is not supported" warnings stopped, because those MIME types are now supported.
On the PDF side, the structurally broken legacy files that the stock extractor gave up on during import come back with text. Not all of them, image-only scans still need OCR, but the ones whose only sin was a malformed structure tree or a `RichMedia` annotation are fine now.
It's maybe 300 lines total and most of that is boilerplate around four small parsers. Almost none of the time went into the parsing.
# Vue 3 + Vite 8 + Tailwind CSS v4 on Sitefinity CMS: Complete Setup Guide with Code Splitting
> **Stack:** Sitefinity 15+ (ASP.NET MVC) | Vue 3.5 | Vite 8 (Rolldown) | Tailwind CSS v4 | shadcn-vue | TypeScript
>
> This guide walks you through adding a modern Vue 3 frontend to a Sitefinity CMS project with production code splitting, design-mode support, search indexing, and a dev impersonation controller. Every file is included, you can hand this to an LLM or a developer and get a working setup.
>
> **Updated (June 2026)** after months of running this stack in production: added the Sentry error bridge (Vue 3 silently swallows widget errors -- you want this), corrected the `[ColorPalette]` prerequisite (it does not exist in MVC, verified by reflection), bumped dependency pins to what actually ships today, documented the `
}
else
{
var jsPath = "/ResourcePackages/MyTheme/assets/dist/vue3/vue3-runtime.js";
var cssPath = "/ResourcePackages/MyTheme/assets/dist/vue3/vue3-runtime.css";
@* ES module, enables dynamic import() for chunk loading.
Module scripts are deferred by default, so execution order is the
same as placing a classic
@Html.StyleSheet(cssPath + "?v=" + Util.FileHash(cssPath), "head")
}
```
### Include in Your Layout
In your Sitefinity layout `.cshtml` (e.g., `Mvc/Views/Layouts/default.cshtml`), add the partial at the end of the ``:
```razor
@* Sitefinity head sections *@
@Html.Section("head")
@* Page content *@
@Html.SfPlaceHolder("Body")
@* Sitefinity script sections *@
@Html.Section("scripts")
@* Vue 3 runtime, must be AFTER all Sitefinity sections *@
@Html.Partial("Vue3")
```
The `` works too -- a JSON script block is inert and survives HTML processing that can occasionally interfere with `` content. The `index.ts` selector below accepts both styles, so pick per view.
### index.ts (Widget Entry Point)
Create at `assets/src/vue3/widgets/Mvc/Views/Faq/index.ts`:
```typescript
import { createApp } from '@/sentry-vue' // see Section 7 "Error monitoring"; use 'vue' if you skipped Sentry
import { registerWidget } from '@/widget-registry'
import FaqApp from './FaqApp.vue'
function mount() {
document.querySelectorAll('[data-widget="faq"]').forEach(el => {
// Skip already-mounted elements (prevents double-mounting in design mode)
if (el.dataset.vueMounted) return
el.dataset.vueMounted = 'true'
// Parse the JSON config from the data island. Accept both island styles:
// and
{{ serverData.heading }}
{{ serverData.description }}
{{ faq.question }}
```
**Now add it to the widget map in `runtime.ts`:**
```typescript
const widgetMap: Record Promise> = {
'[data-widget="faq"]': () => import('../widgets/Mvc/Views/Faq'),
}
```
Build and drop it onto a page in Sitefinity's page editor. It should show "Click to add content" until you add FAQ items.
> **HMR tip:** If you're running `npm run dev:hmr` (see Section 13), you can now edit `FaqApp.vue`, change a class, tweak the heading markup, add a new element, and see the change reflected in the browser within milliseconds, without a page refresh. On a CMS like Sitefinity where a full page load takes 5-15 seconds, this makes iterating on widget templates dramatically faster. You don't need to rebuild, restart IIS, or wait for the app pool to recycle. Just save and see.
---
## 10. Adding More Widgets
The pattern is always the same:
1. **Create the controller** extending `Vue3Controller`, serialize config to JSON, build `IndexContent` for search
2. **Create the `.cshtml` view**: `` + `
`
3. **Create the Vue entry** (`index.ts`), `querySelectorAll` + `createApp` + `registerWidget(mount)`
4. **Create the Vue component** (`YourWidgetApp.vue`), receives `serverData` prop
5. **Add to `widgetMap`** in `runtime.ts`
Each new widget automatically gets its own chunk. Pages that don't use it never download its code. And with HMR running (Section 13), changes to any widget's `.vue` files appear instantly in the browser, no waiting for Sitefinity to reload.
```typescript
// runtime.ts, just add one line per widget
const widgetMap: Record Promise> = {
'[data-widget="faq"]': () => import('../widgets/Mvc/Views/Faq'),
'[data-widget="hero"]': () => import('../widgets/Mvc/Views/Hero'),
'[data-widget="stats"]': () => import('../widgets/Mvc/Views/Stats'),
'[data-widget="team"]': () => import('../widgets/Mvc/Views/Team'),
'[data-widget="pricing"]': () => import('../widgets/Mvc/Views/Pricing'),
'#admin-tool-root': () => import('../widgets/Mvc/Views/AdminTool'),
// Each import() boundary = one chunk in the build output.
// Keys are ANY CSS selector -- data attributes are the convention, but an
// id selector works fine for one-off admin tools.
}
```
**Always-on code should NOT go through the widgetMap.** Anything every page needs (a layout shell, a global nav enhancement) gains nothing from lazy loading -- `import` it statically at the top of `runtime.ts` instead, so it ships in the entry chunk and runs immediately:
```typescript
// runtime.ts -- eager, not lazy: every page renders the sidebar shell
import '../widgets/layout/sidebar-layout'
```
---
## 11. Dev Impersonation Controller
When developing against Sitefinity, you often need to test pages as different users without going through your full SSO/login flow every time. This widget controller impersonates a Sitefinity user by username on dev environments. **It is gated behind `Util.IsDev`**: it does nothing in production.
### The pattern
The core of Sitefinity impersonation is three API calls:
```csharp
UserManager userManager = UserManager.GetManager();
using (new ElevatedModeRegion(userManager))
{
User user = userManager.GetUser(username);
SecurityManager.AuthenticateUser(null, username, true, out user);
}
```
`ElevatedModeRegion` bypasses permission checks (you're not logged in yet). `AuthenticateUser` with `persistent: true` creates a Sitefinity session cookie. After this call, `ClaimsManager.GetCurrentIdentity()` returns the impersonated user for the rest of the request, and subsequent requests use the session cookie.
### DevLoginController.cs
Create at `Mvc/Controllers/DevLogin/DevLoginController.cs`:
```csharp
using System;
using System.Web;
using System.Web.Mvc;
using Telerik.Sitefinity.Data;
using Telerik.Sitefinity.Frontend.Mvc.Infrastructure.Controllers.Attributes;
using Telerik.Sitefinity.Mvc;
using Telerik.Sitefinity.Security;
using Telerik.Sitefinity.Security.Model;
using Telerik.Sitefinity.Services;
using Telerik.Sitefinity.Web.UI;
using YourApp.Controls.Code;
namespace YourApp.Controls.Mvc.Controllers.DevLogin
{
///
/// Dev impersonation controller. Authenticates as a Sitefinity user
/// by username and redirects to a target page.
///
/// Setup: Create a Sitefinity page (e.g. at path /dev/login),
/// drop this widget on it, and publish.
///
/// Usage: /dev/login?user=admin@example.com&returnurl=/dashboard
///
/// Only works when Util.IsDev is true. Returns a blank page otherwise.
///
[EnhanceViewEnginesAttribute]
[ControllerToolboxItem(
Name = "DevLogin_MVC",
Title = "Dev Login",
SectionName = "Developer",
CssClass = "sfLoginIcn sfMvcIcn"
)]
[IndexRenderMode(IndexRenderModes.NoOutput)]
public class DevLoginController : Controller
{
public ActionResult Index(string user, string returnUrl)
{
// Do nothing in design mode or on non-dev environments
if (SystemManager.IsDesignMode || !Util.IsDev)
return View("Default");
if (string.IsNullOrWhiteSpace(user))
return Content("Missing ?user= parameter");
// Impersonate the requested user via Sitefinity's security API
UserManager userManager = UserManager.GetManager();
using (new ElevatedModeRegion(userManager))
{
User sfUser = userManager.GetUser(user);
if (sfUser == null)
return Content($"User '{user}' not found in Sitefinity");
SecurityManager.AuthenticateUser(null, user, true, out sfUser);
}
// env=prod sets a server-side session variable that Vue3.cshtml reads
// to serve the production build instead of the Vite dev server.
// Without env=prod, the variable is cleared so HMR mode is restored.
var env = Request.QueryString["env"];
if ("prod".Equals(env, StringComparison.OrdinalIgnoreCase))
{
Session["vite_prod"] = "1";
}
else
{
Session.Remove("vite_prod");
}
return Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
}
}
}
```
### Default.cshtml
Create at `Mvc/Views/DevLogin/Default.cshtml`:
```razor
Dev login, this widget only works on dev environments.
```
### Usage
```
https://dev.yourapp.com/dev/login?user=admin@example.com&returnurl=/dashboard
https://dev.yourapp.com/dev/login?user=editor@example.com&returnurl=/content
https://dev.yourapp.com/dev/login?user=admin@example.com&env=prod&returnurl=/dashboard
```
You can extend this with a `role` parameter that maps to predefined test accounts, IP allowlisting for CI runners, or whatever your project needs. The pattern above is the minimal working version, the Sitefinity auth calls are the part that's hard to figure out from the docs.
---
## 12. Build and Verify
```bash
cd ResourcePackages/MyTheme
npm run build
```
### Expected output
```
assets/dist/vue3/
├── vue3-runtime.js ← Entry (imports + scanner)
├── vue3-runtime.css ← All CSS (single file)
└── chunks/
├── vendor-vue-a1b2c3.js ← Vue 3 + Reka UI (~45 KB gzip)
├── shadcn-ui-d4e5f6.js ← shadcn-vue components
├── Faq-7g8h9i.js ← FAQ widget chunk
└── common-j0k1l2.js ← Shared code (if 2+ widgets share modules)
```
### Verify in the browser
1. Navigate to a page with the FAQ widget
2. Open DevTools > Network tab
3. You should see:
- `vue3-runtime.js` (entry)
- `vendor-vue-*.js` (Vue core)
- `Faq-*.js` (FAQ widget chunk)
4. You should **not** see chunks for widgets that aren't on the page
### Verify design mode
1. Open the page in Sitefinity's page editor
2. Drag the "FAQ" widget onto the page
3. It should show "Click to add content" (because `IsEmpty` is true)
4. Edit the widget, add FAQ items, save
5. The widget should render immediately without a page refresh
---
## 13. Vite Dev Server with HMR
> **This is arguably the single biggest developer experience win of this entire stack.** Sitefinity pages take 5-15 seconds to load, and an app pool restart after a C# build takes 30-90 seconds. Without HMR, every CSS tweak or template change means waiting through that cycle. With HMR, Vue template and style changes appear in the browser in milliseconds, the page stays loaded, component state is preserved, and you never wait for IIS. If you only set up one "nice to have" from this guide, make it this.
For development, you can run the Vite dev server alongside Sitefinity for instant hot module replacement, change a `.vue` file and see it update in the browser without a full refresh.
### How it works
1. Sitefinity serves the HTML page (with widget mount points)
2. `Vue3.cshtml` detects dev mode and points `
```
The key thing to remember: **you need to add CSS for the styling classes**. The library creates classes like `hs-2`, `hs-3`, etc., and you style them to match your original heading appearance:
```css
h2, .hs-2 {
/* Your H2 styles here */
}
h4, .hs-4 {
/* Your H4 styles here */
}
```
Then just call it on your container:
```javascript
// Fix headings in your main content area
SemanticHeadingHierarchy.fix('.sf_cols');
// Enable logging to see what gets fixed
SemanticHeadingHierarchy.fix('.content', { logResults: true });
```
## The Bottom Line
Building accessible websites shouldn't require constantly policing content editors or fighting against the CMS. Sometimes you need a technical solution that just handles the problem automatically.
If you're dealing with broken heading hierarchies on your Sitefinity (or any other CMS) sites, give this library a shot. Your accessibility audits will thank you, your users will have a better experience, and your content editors can focus on what they do best - creating great content.
**Links:**
- [GitHub Repository](https://github.com/sitefinitysteve/semantic-heading-hierarchy)
- [npm Package](https://www.npmjs.com/package/semantic-heading-hierarchy)
---
*Made with ❤️ for better web accessibility*
# Easier, Sitefinity ContentLocations API
So lets be real, the API is a lot... and like it'll return you a PageId which you'll then have to manually resolve (bleh).
https://www.progress.com/documentation/sitefinity-cms/for-developers-get-all-locations-of-an-item
However as of somewhere around Sitefinity 13 they added UI elements under content in the backend where you can SEE the content links in the UI, the counts and the locations. This is pretty great, but what's even better is getting at that data is just a simple GET request to their endpoints!
You can open the dev tools and inspect the query and result, but it's SO FAST and SO EASY if you need to show where pages or docs are located in one of your widgets.
So here's an example from documents
### Getting the Counts of a Document
```
{baseUrl}/sf/system/documents({documentId})/displaypagescount
```
```
{
"@odata.context": "https://www.medportal.ca/sf/system/$metadata#Edm.Int32",
"value": 4
}
```
Or
### Getting the actual location results of that document
```
{baseUrl}/sf/system/documents({documentId})/DisplayPages
{baseUrl}/sf/system/documents({documentId})/LinkItems
```
```
{
"value": [
{
"Title": "Course Registration",
"LiveUrl": "/academics/registration/course-enrollment",
"Url": "/academics/registration/course-enrollment"
},
{
"Title": "Academic Guidelines",
"LiveUrl": "/academics/information/academic-guidelines",
"Url": "/academics/information/academic-guidelines"
},
{
"Title": "Resources",
"LiveUrl": "/files/academic-docs/default-source/guidelines/Study-Guide-Template-(Mar-2024)---Section-1",
"Url": "/files/academic-docs/default-source/guidelines/Study-Guide-Template-(Mar-2024)---Section-1"
},
{
"Title": "Academic Support Resources",
"LiveUrl": "/support/academic-support-resources",
"Url": "/support/academic-support-resources"
}
]
}
```
Just a small note, that the INITIAL query to that endpoint takes a few moments to return, but the subsequent results will be cached.
# Instructions for Sitefinity Autogenerated Designers
Ever been trying to figure out how to add instructions to those autogenerated designers without having to build custom ones?
Turns out there are a few simple ways to do this.
## ContentSection Grouping
First off, you can use the ContentSection attribute to group stuff:
```csharp
[Progress.Sitefinity.Renderer.Designers.Attributes.ContentSection("Advanced Settings")]
```
Just a nice way to keep things organized under collapsible sections.
## Read-Only Property Hack
Here's the cool part - if you make a public string property with only a getter, it shows up as non-editable text:
```csharp
[Progress.Sitefinity.Renderer.Designers.Attributes.ContentSection("Advanced Settings")]
public string Instructions { get; } = "What makes this cool is you can have inline instruction text.";
```
Super handy for adding instructions right in the UI without any extra work.
## Labeling Your Instructions
You can make it even clearer by adding a DisplayName to your read-only property:
```csharp
[Progress.Sitefinity.Renderer.Designers.Attributes.ContentSection("Advanced Settings")]
[DisplayName("Here's how you can use spaces")]
public string InstructionsSubText { get; } = "What makes this cool is you can have inline instruction text.";
```
## Regular Old Description Attribute
And don't forget the standard Description attribute still works fine for adding hints under specific fields:
```csharp
[Progress.Sitefinity.Renderer.Designers.Attributes.ContentSection("Advanced Settings")]
[Description("Select the maximum number of items to display in this widget.")]
public int InstructionsDescription { get; set; }
```
## Full Example
Here's the whole thing put together:
```csharp
#region Properties
public string SomeProperty { get; set; }
[Progress.Sitefinity.Renderer.Designers.Attributes.ContentSection("Advanced Settings")]
public string Instructions { get; } = "What makes this cool is you can have inline instruction text.";
[Progress.Sitefinity.Renderer.Designers.Attributes.ContentSection("Advanced Settings")]
[DisplayName("Here's how you can use spaces")]
public string InstructionsSubText { get; } = "What makes this cool is you can have inline instruction text.";
[Progress.Sitefinity.Renderer.Designers.Attributes.ContentSection("Advanced Settings")]
[Description("Select the maximum number of items to display in this widget.")]
public int InstructionsDescription { get; set; }
#endregion
```

## Why This Matters
Just a few quick reasons I'm liking this approach:
- Works with standard autogenerated designers
- No custom coding needed
- Instructions stay with the code where they belong
- Content editors actually get guidance where they need it
Give it a try if you're tired of getting questions about how to use your widgets. Let me know if you've found other tricks for this - always on the lookout for simpler solutions.
Huge thanks to Christian May from [AVIXA](https://www.avixa.org/) hit them up for Sitefinity Developement too!
# Nativescript iOS Simulator Laravel API Error: The certificate for this server is invalid
This took me a bit to find an answer so I figured I'd add it in here to hopefully help others.
Here's the error when you try to call your API in Nativescript on iOS
```
The certificate for this server is invalid. You might be connecting to a server that is pretending to be "localtest.test" which could put your confidential information at risk.
```
/Users/YOUR_USERNAME/Library/Application Support/Herd/config/valet/CA/LaravelValetVASelfSigned.pem
Then in the simulator, go to Settings -> General -> About -> Certificate Trust Settings
In finder drag and drop the certificate into the simulator and it will be added to the trusted list.
You might need to back out to settings home, then back in to see it enabled
Now in your App_Resources\iOS\info.plist add
```
NSAppTransportSecurity
NSAllowsArbitraryLoads
NSExceptionDomains
tripclok.test
NSExceptionAllowsInsecureHTTPLoads
NSIncludesSubdomains
NSTemporaryExceptionAllowsInsecureHTTPLoads
```
```
ns clean
ns run ios
```
Should be good now
# This extension is not installable on any currently installed products Visual Studio 2022 ARM
There's so many Extensions in the Visual Studio marketplace, but very few have been updated for ARM, which is incredibly frustrating as most will *just work* right out of the box.
You'll click on it and get a "This extension is not installable on any currently installed products" error.
I can't guarantee ALL will work perfectly, but here's how to get them to work
* Download the VSIX
* Run, if it works, you're done! If not... continue
* Install 7Zip
* Open the VSIX in 7Zip
* Open the "extension.vsixmanifest" file
* Look for the ProductArchitecture node with "amd64"
* Change it to "arm64"
* Save it back to the vsix
...now install fine
# Running Sitefinity on Apple Silicon with Parallels
Just to prefix here, this is running on an M3 MAX Macbook Pro, but it runs so much faster than my Bootcamped 2019 Core i9 16 inch Macbook. So this is Sitefinity MVC using .net 4.8, not Core just heads up. But the SF backend (which is 4.8) runs just fine...
## Step 1: Install windows
We're going to assume you have installed Parallels for Apple Silicon here, so now just install windows (and license it). I migrated from windows by just copying my Sitefinity projects folder straight into the Windows VM, no changes.
[Install Windows](https://kb.parallels.com/1253750)
## Step 2: Install VS 2022 ARM
[Visual Studio ARM](https://devblogs.microsoft.com/visualstudio/arm64-visual-studio-is-officially-here/)
Now you can load your site and build it, not really required for this if you already have the SitefinityWebApp.dll in the bin folder from the copy... but to develop further, GONNA NEED IT
## Step 3: Setup IIS
- [IIS Setup Instructions](https://www.how2shout.com/how-to/how-to-enable-iis-internet-information-services-on-windows-11.html)
- [Sitefinity Required Features](https://www.progress.com/documentation/sitefinity-cms/configure-the-iis-to-host-sitefinity-projects)
- [Install IIS Rewrite module](https://www.iis.net/downloads/microsoft/url-rewrite)
## Step 4: Create IIS Site
- You should already know how to do this, but create a new site in IIS, point it at your Sitefinity Folder. We're going to assume you called it "dev.mysite.com" for the purposes of the following steps
- Open said folder in windows explorer, right-click, properties, and in Security give IIS AppPool\dev.mysite.com access to the folder
## Step 5: Add your SQL Server
- [Install MS Sql with Docker](https://www.parallels.com/blogs/microsoft-sql-apple-silicon-docker-desktop/)
I like to setup the persistance mode so my databases are external to docker, but it fundamentally doesn't matter I suppose
## Step 6: Connect to SQL to test
- Download the free [Azure Data Studio for Apple Silicon](https://learn.microsoft.com/en-us/azure-data-studio/download-azure-data-studio?tabs=win-install%2Cwin-user-install%2Credhat-install%2Cwindows-uninstall%2Credhat-uninstall)
- You're probably going to want to install the following extensions
- Admin Pack for SQL Server
- SQL Server Arent
- SQL Server Dacpac
- SQL Server Import
- Inside data studio, create your connection. The Server is your IP, Auth type of SQL Login, and the username and password are what you set in the docker install terminal code from Step 4.
- From here you can use one of the import tools to restore your .bak (from the dashboard), or import your .bacpac
## Step 7: Add hostfile entries
- Inside windows, with a text editor running in Admin mode, open C:\Windows\System32\drivers\etc and edit the "hosts" file.
- I like to add a host file entry for my SQL instance so just add the following, obviously use YOUR IP address from MacOS
- 192.168.2.208 localsql
- Now add the hostfile entry for your local SF site, so example is below, again, use your IP, this time get the ip from in windows because it's the local parallels VM IP.
- 10.211.55.3 dev.mysite.com
- In MacOS you'll need to add the same entries [Follow this to edit](https://kevdees.com/editing-host-files-quickly/) Make sure the IP addresses here are to your Windows VM (obviously, so same as the windows hostfile entries)
- So now the "dev.site.com" should be accessable on both Host and VM. Honestly the windows side I dont think is totally nessesary, but for debugging purposes I might need to get at the site on the windows side, so couldn't hurt.
## Step 7: Edit your connection string
- Inside your Sitefinity project, edit the App_Data\Sitefinity\Configuration\Data.config and change the SQL connection string to be localsql (which we set above) and then whatever the credentials are to access it, sa\pw whatever.
## Step 8: Create the certs
So now we need to make the HTTPS certs so our browsers won't freak out on us.
- Inside of Admin mode powershell run the following
New-SelfSignedCertificate -DnsName "dev.mysite.com", "dev.mysite.com" -CertStoreLocation "Cert:\\LocalMachine\\My" -NotAfter (Get-Date).AddYears(10)
- Install it into windows trusted certs through the steps [here](https://www.sitefinitysteve.com/blog/remote-certificate-is-invalid)
- Now go back to IIS
- Find your site
- Click Bindings
- Add a new HTTPS binding, and choose this cert
## Step 9: Validate the cert in MacOS
- Inside of MacOS, Safari or Chrome, run "https://dev.site.com"
- It should fire up with the Sitefinity config\loading status screen
- But you should also notice the cert is invalid...
- Follow [these steps](https://iboysoft.com/news/how-to-trust-a-certificate-on-mac.html) to dowload the cert, add to your keychain, and trust it
## Step 10:
- You're done, reload the site and everything should be just as it was back in windows, just faster (probably)
# Defining which Controller Action or Json route to POST to when there's more than one on the page
Sitefinity Controllers are fantastic, you can easily expose public properties and the designers handle saving your preferences back. But what happens when you have 2 controllers on the page, and they both specify the route of *Foo*
```
[HttpPost]
public JsonResult Foo()
{
return Json(new {
data = this.SomeProperty
});
}
```
* Widget Instance 1 this.SomeProperty is set to "Steve"
* Widget Instance 2 this.SomeProperty is set to "Dave"
So think about this now, if our page is /bar, and this widget exists on that page TWICE, how would one POST back to /foo/bar and get back Steve if you want Steve, or Dave if you want Dave.
What will happen by default at /foo/bar is it's going to give you the first Controller instance, in this case you're ALWAYS going to get "Steve".
So how do we find Dave?
It's actually REALLY simple, Sitefinity allows you to pass in the control id of Controller you want right on the querystring with *sf_cntrl_id*
```
axios.post("/foo/bar?sf_cntrl_id=" + thewidgetid)
```
Now we're getting Dave back
The only missing piece is how to get that ControlId? Well it's all done for you just part of the ViewData, Sitefinity automatically adds it!
```
public string ControlDataId
{
get
{
var controlDataId = "";
var keyName = Telerik.Sitefinity.Mvc.Proxy.MvcControllerProxy.ControllerKey;
if (this.ViewData != null && this.ViewData.ContainsKey(keyName))
{
controlDataId = this.ViewData[keyName].ToString();
}
return controlDataId;
}
}
```
That's all there is to it!
Oh, one thing to note, the Html.BeginFormSitefinity helper will AUTOMATICALLY add the controlId stuff in for you in the current instance it's cshtml runs under... but you can't BeginFormSitefinity an XHR post as well obviously.
# Timezone conversion from Sitefinity OData Event API Service
So this all came about with the ask for showing a kendoTooltip popup when hovering on an event in the scheduler. The issue with showing the data is that none of the data you want to show comes across in the default endpoint. It's basically the title and dates, enough for the item to render... and as it should be, you want it fast and lean.
The rest of the data can be pulled (quickly) from the OData endpoint at /api/default/events
```
//Something like this
var itemdefaulturl = item.find(".event-item").data("itemdefaulturl");
var route = "/api/default/events?$filter=ItemDefaultUrl eq '" + itemdefaulturl.replace("'", "%27%27") + "'";
```
*Note the replace as single quotes in a urlname (thanks for not filtering those out on create btw Sitefinity...) will break the querystring.*
Anywho, the date format from the native endpoint is WCF, and it's an ISO string from the OData endpoint.
So here's how you can convert that to actually use it properly without going insane
```
var eventStartDate = new Date(data.EventStartWithOffset);
eventStartDate.setMinutes(eventStartDate.getMinutes() + eventStartDate.getTimezoneOffset());
var dateString = kendo.toString(eventStartDate, "MM/dd/yyyy h:mm tt")); //Let kendo convert it to readable for you
```
# How to get the saved Controller properties for a widget on a page
You might find it handy to want to know what the public property data is on a widget (Mvc Controller) sitting on a page. Like perhaps you need to pull in the data over a webservice. Doesn't matter, I'm not here to speculate on what your needs are!
Here's the code:
GetSavedControllerData.csView on GitHub public static class Helper
{
/// <summary>
/// Helper to query the sitefinity database for a Controllers saved properties on a page
/// You can get the controllers widget id like this this.ViewData["controlDataId"]
/// </summary>
/// <param name="controlId"></param>
/// <returns></returns>
public static List<ControlPropertyData> GetSavedControllerData(Guid controlId)
{
var data = new List<ControlPropertyData>();
//We need to lookup the param values from the database, as we can't be sure this widget only exists once on the page
var pageManager = PageManager.GetManager();
var provider = (IOpenAccessDataProvider)pageManager.Provider;
var context = provider.GetContext();
//Get the settings property Id for this control Id
var settingsIdQuery = $"select id from sf_control_properties where control_id = @controlId and nme = 'Settings'";
var controlIdParam = new OAParameter("controlId", controlId);
var settingsId = context.ExecuteScalar<Guid>(settingsIdQuery, controlIdParam);
//The properties are children of settings linked by the prnt_prop_id
var propertyQuery = $"select id, nme, val from sf_control_properties where prnt_prop_id = @settingsId order by nme";
var settingsIdParam = new OAParameter("settingsId", settingsId);
//Okay now we go query the control to get it's current properties
//Keep in mind it only stores CHANGED data
data.AddRange(context.ExecuteQuery<ControlPropertyData>(propertyQuery, settingsIdParam).ToList());
return data;
}
}
**Very very important note** though. The database only stores CHANGED properties.
So lets say you have a public property called Title
```
public string Title { get;set; } = "Some title";
```
The above code snippet **will not** return a property called Title in the array unless you have edited the widget and changed the Title to something other than "Some title". So you'll need to read the defaults from the Controller if what you need isn't there.
You can get the widgets (Controllers) control id with the ViewData
```
this.ViewData["controlDataId"]
```
# Unable to directly link to a backend Advanced Configuration Section
Came across this one the other day and I think I need to write it down so it's just out there.
At the time of this post when you visit the Sitefinity Advanced config (/Sitefinity/Administration/Settings/Advanced) and you click the items in the left page the URL doesn't change in the browser. So if you reload the page you completely lose your selected node. HOWEVER you should be able to manually send someone to a specific root node by adding the name you see into the route. Example /Sitefinity/Administration/Settings/Advanced/Toolboxes sends you right to the toolbox and that's all you see in the left page. Sadly we can't link to deeper than the root level.
Now I've always just read the name from the left pane and used that in the Url, but yesterday the name of "SomeWidgetConfiguration" (/Sitefinity/Administration/Settings/Advanced/SomeWidgetConfiguration) wasn't working, just 404ing.
The *FIX* was the naming of the ConfigSection class. I guess you need to call it SomeWidgetConfig (not Configuration), and then the "Config" is automatically stripped out of the UI and the Url now knows which section to route it to.
So once I made the change the left sidebar now said "SomeWidget" and the resulting link is /Sitefinity/Administration/Settings/Advanced/SomeWidget.
Weird and nice to know eh...
# How to join a party with XBox xCloud and not get the ms-xbl-multiplayer link error
Had this issue over the weekend trying to play Sea of Thieves with 1 XBox, and 2 people with Cloud accounts. Branching out it seems the problem is generically associated with connecting 2 cloud accounts into a game.
So lets say you launch the XBox app, connect to your cloud game. Your friend sends you an invite to their party from their cloud. You press your controllers xbox button, or the Win-G gamebar shortcut... and accept the invite. Boom, Windows store popup because it can't find "ms-xbl-multiplayer link"... every time.
So the problem is that it's looking for the installed copy of the cloud game you're trying to play.
The way to fix it is you have to bypass the entire gamebar system altogether!
* Both launch the game via xCloud (xbox.com/play)
NOT THE XBOX APP!
* One of you send the invite to the friend by clicking on the XBox button top left in the browser and NOT from your controller. This is running through the rendered cloud instance, not your PC...
* The other person now also uses the browser based top left xbox button to accept the invite.
So I guess this works because the game is running on a cloud xbox instance, not your PC directly (well I guess obviously)
Fixed us up for Sea Of Thieves, should work fine for you as well!
# Auditing page permissions
We have a huge site with lots of pages and nesting. If you've ever used Sitefinity's permission editor you can appreciate how absolutely painful it is to navigate that UI. It's incredibly slow, and there's absolutely no way you're going through every. single. page.
So I came up with a quick SQL script to audit the custom page permissions. It'll show you basically broken permissions, not inherited on every page... which is what you should be wanting, but you'll also see the parent pages incase pages have the same name somewhere in the structure.
sp_getPagePermissions.sqlView on GitHub CREATE PROCEDURE [dbo].[sp_getPagePermissions]
AS
BEGIN
-- SET NOCOUNT ON added to prevent extra result sets from
-- interfering with SELECT statements.
SET NOCOUNT ON;
-- Insert statements for procedure here
SELECT sf_permissions.object_id AS 'page_node_id', sf_permissions.principal_id AS 'user_id',
sf_sitefinity_profile.nickname, sf_page_node.title_ as 'title', sf_page_node.url_name_ as 'url_name', sf_page_node_1.title_ AS 'parent_title', sf_page_node_1.url_name_ AS 'parent_url_name', sf_permissions.grnt, sf_permissions.[deny], sf_permissions.last_modified, sf_permissions.voa_version
FROM sf_permissions INNER JOIN
sf_user_profile_link ON sf_permissions.principal_id = sf_user_profile_link.user_id INNER JOIN
sf_sitefinity_profile ON sf_user_profile_link.profile_id = sf_sitefinity_profile.id INNER JOIN
sf_page_node ON sf_permissions.object_id = sf_page_node.id INNER JOIN
sf_page_node AS sf_page_node_1 ON sf_page_node.parent_id = sf_page_node_1.id
WHERE (sf_permissions.set_name = 'Pages') AND (sf_user_profile_link.user_profile_type_name = 'Telerik.Sitefinity.Security.Model.SitefinityProfile')
order by sf_page_node_1.url_name_, sf_page_node.url_name_
END
GO
There's one change you might need to make, and that's to link it to sf_users instead of through sf_user_profile_link. I have it through there because we're on Ldap and there's nothing in sf_users, but every user has a profile so I can sneak out the nickname through that (nickname is the email).
So from here just call it into SQL, OR expose it through a ServiceStack service, or directly from backend code into a custom widget, it's quite fast to execute.
Enjoy!
# Laravel is better than Sitefinity for small projects
Our recent site we decided to move from Sitefinity (C#\Asp) as the platform to Laravel (PHP). Few reasons, but can be boiled down to the right tool for the job.
Laravel is a dream to work with. It's almost entirely file system based, like there is a database, but everything outside of "data" is in a physical file I can touch, and changes are reflected instantly on reload. Compare this to Sitefinity which is almost entirely database driven, and every little code change requires a 5-15 second "Startup" task to run; that adds up fast.
Sitefinity though comes with just about everything out of the box you need, and modules can turn things on and off. This includes even the basic concept of a backend. Laravel needs custom packages added like Nova and Jetstream to manage data and user profiles. It's a bit of work to get them all looking native like it's one system and not 2 seperate ones.
Licensing, oh my nemesis Licensing... Sitefinity USED to have a free tier. It's popularity as an asp.net CMS exploded because of that. Could be used for hobby sites, blogs, etc... but it got abused and companies decided they would just use the free version and not pay for it when delivering to their clients. So with v4 they killed free, and fundamentally just cut the head off of Sitefinity (IMO). Now the only people who use it are employees at partners creating the sites, or employees at said corporations who can afford the high license fee. It's fine, they need to get paid for the work, but for a SMALL project it makes no sense, especially factoring a yearly license renewal fee. If you're only pulling in 60k a year, do you want 20k to go to Sitefinity and uCommerce, probably not. Don't forget as well using the .Core renderer you're also increasing hosting costs as you need to run 2 sites (backend and frontend) concurrently.
Laravel\Jetstream\Inertia is VueJS forward, it's what you build on. It's modern, fast, and better to deal with using TailwindCss than having to hack it into every single Sitefinity ResourceView. That's SO MUCH WORK because you need to cover every single widget in the toolbox.
Is Sitefinity technically OOTB more secure? Yeah for sure, but a properly secured site is fine, and there's lots of free packages to add to beef up just about any part.
The only thing to truly be missed is that PHP isn't compiled on build which means there's not really great intellisense or type checking, bugs kind of just show up, but the error message screen is better than asp anyway, so it's here and there...
## TL;DR;
* Not a lot of content changes required, so SF is overkill
* Sitemap is basically static, again Overkill
* No Licensing fees
* No Startup times, Laravel PHP is FAST
PHP isn't without it's issues, but for small projects I think it's going to be the way forward for us unless someone comes in with an active Sitefinity license. We'll crush it for you either way.
# Adding a class to HTML when a custom widget is on the page
The scenario where you need to change styling on the site, or inform other widgets based on some *other* widget on the page is so common in Sitefinity. It can quite easily be done client-side as well, something like...
```
if($(".my-toolbar").length > 0){
$("body").addClass("toolbar-exists");
}
```
Now you could style the rest of the page based on the idea that a toolbar now exists. Maybe your header needs extra margins, or some element needs to be absolute, or heck maybe it's just a 3/9 grid that has a toolbar.
The issue with clientside though is the page has to render, then the script has to run, and you're *going* to see some popping of elements as that class is applied to body and it's rules take effect.
The PROPER way to do this is to put it into the Server-Side so the page renders with that class. Let me prefix this by saying it's something Sitefinity should add to their API, something like the ICustomWidgetVisualization where I can just tell it the class name I want, and if it's on the page it'll render in there. Sitefinity team, please take note, this would be really quite a great API feature for devs, solve a lot of problems for juniors or people new to the platform.
Anyway, here's what I came up with.
Inspiration came from the javascript feather widget which was somehow able to render content in the header or bottom of the page. You can checkout the code over on the [(old?) git repo](https://github.com/Sitefinity/feather-widgets/blob/master/Telerik.Sitefinity.Frontend.InlineClientAssets/Mvc/Controllers/JavaScriptController.cs). The widget hooks into the PagePreRender to inject it into those places!... so that means we can also hijack the rendering!
Lets start by adding this to your Controller Index method somewhere
```
var context = Telerik.Sitefinity.Services.SystemManager.CurrentHttpContext;
if(context != null){
var page = context.CurrentHandler.GetPageHandler();
if (page != null)
{
page.PreRenderComplete += this.PagePreRenderCompleteHandler;
}
}
```
GetPageHandler() is an extension method so go ahead and add this using clause as well
```
using Telerik.Sitefinity.Frontend.Mvc.Infrastructure;
```
Now here's the handler we're going to use
```
///
/// Handler called when the Page's PreRenderComplete event is fired.
///
/// The sender.
/// The instance containing the event data.
private void PagePreRenderCompleteHandler(object sender, EventArgs e)
{
try
{
var widgetClassName = "my-toolbar";
var page = (Page)sender;
foreach(Control c in page.Controls)
{
if(c.GetType().Name == "MvcMasterPage")
{
var layout = c;
foreach(var child in layout.Controls)
{
if(child is LiteralControl)
{
var literal = (LiteralControl)child;
if (literal.Text.Contains("
```
# Custom Function Validation in KendoUI Spreadsheet
This is something I needed to come up with the other day, but the documentation is a bit too all over the place. Basically all I wanted to do was to take a cell and make sure it's value existed in an array I populated from a remote source. In a nutshell, call a custom javascript function for the cell...
So here's a small example, just checks a local array, stripped clean for simplicity
It's important to note the syntax in the validation definition, it's [R1C1](https://bettersolutions.com/excel/formulas/cell-references-a1-r1c1-notation.htm) notation apparently. Relative cell notation, so R[0]C[0] is the current cell, which is why that's passed into the function.
arrayCheck.htmlView on GitHub <!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Kendo UI Snippet</title>
<link rel="stylesheet" href="https://kendo.cdn.telerik.com/2021.3.1207/styles/kendo.common.min.css">
<link rel="stylesheet" href="https://kendo.cdn.telerik.com/2021.3.1207/styles/kendo.rtl.min.css">
<link rel="stylesheet" href="https://kendo.cdn.telerik.com/2021.3.1207/styles/kendo.default.min.css">
<link rel="stylesheet" href="https://kendo.cdn.telerik.com/2021.3.1207/styles/kendo.mobile.all.min.css">
<script src="https://code.jquery.com/jquery-1.12.3.min.js"></script>
<script src="https://kendo.cdn.telerik.com/2021.3.1207/js/angular.min.js"></script>
<script src="https://kendo.cdn.telerik.com/2021.3.1207/js/jszip.min.js"></script>
<script src="https://kendo.cdn.telerik.com/2021.3.1207/js/kendo.all.min.js"></script>
</head>
<body>
<script>
var items = ['apple', 'banana', 'pear']
kendo.spreadsheet.defineFunction("ISINARRAY", function(fruit){
console.log("OKay");
return items.indexOf(fruit.toLowerCase()) > -1;
}).args([
[ "fruit", "string"]
]);
</script>
<div id="spreadsheet"></div>
<script>
var spreadsheet = $("#spreadsheet").kendoSpreadsheet().getKendoSpreadsheet();
var sheet = spreadsheet.activeSheet();
sheet.range("A1:A20").validation({
comparerType: "custom",
dataType: "custom",
from: 'AND(ISINARRAY(R[0]C[0]) = true)',
type: "reject",
allowNulls: true,
messageTemplate: "Pick apple, banana, pear"
});
</script>
</body>
</html>
Here's another example that shows how to validate for a number
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Kendo UI Snippet</title>
<link rel="stylesheet" href="https://kendo.cdn.telerik.com/2021.3.1207/styles/kendo.common.min.css">
<link rel="stylesheet" href="https://kendo.cdn.telerik.com/2021.3.1207/styles/kendo.rtl.min.css">
<link rel="stylesheet" href="https://kendo.cdn.telerik.com/2021.3.1207/styles/kendo.default.min.css">
<link rel="stylesheet" href="https://kendo.cdn.telerik.com/2021.3.1207/styles/kendo.mobile.all.min.css">
<script src="https://code.jquery.com/jquery-1.12.3.min.js"></script>
<script src="https://kendo.cdn.telerik.com/2021.3.1207/js/angular.min.js"></script>
<script src="https://kendo.cdn.telerik.com/2021.3.1207/js/jszip.min.js"></script>
<script src="https://kendo.cdn.telerik.com/2021.3.1207/js/kendo.all.min.js"></script>
</head>
<body>
<script>
kendo.spreadsheet.defineFunction("GETNUMBER", function(num){
return num * 2;
}).args([
[ "num", "number"]
]);
</script>
<div id="spreadsheet"></div>
<script>
var spreadsheet = $("#spreadsheet").kendoSpreadsheet().getKendoSpreadsheet();
var sheet = spreadsheet.activeSheet();
sheet.range("A1:B2").validation({
comparerType: "custom",
dataType: "custom",
from: 'AND(GETNUMBER(R[0]C[0])>=10, GETNUMBER(R[0]C[0])<=30)',
type: "reject",
messageTemplate: "Enter a number between 5 and 15"
});
</script>
</body>
</html>
## Note
* Note how the args are the variable AND the data type
* defineFunction has to exist BEFORE you initialize the spreadsheet element.
# Adding Telerik Reporting v15+ to Sitefinity in 2021
So jump in the way back machine, Sitefinity v4 shipped with a (really bad\buggy) eComm module. However part of that module was reporting using the [Telerik.Reporting](https://www.telerik.com/products/reporting.aspx) assemblies. Sometime around v12 or 13 they decomissioned the old eComm module (in favour of [uCommerce](https://ucommerce.net/)), and subsequently stopped shipping the Telerik.Reporting DLLs with Sitefinity.
Well, I had to do some more reporting UIs, so we re-licensed the product so we could get the latest v15 assemblies and (more importantly) get access to the Visual Studio 2022 reporting designer. It's not REQUIRED to generate a report, you can do it all through code, but it does simplify it quite substantially.
So the most important part is to follow the [installation doc](https://docs.telerik.com/reporting/telerik-reporting-rest-host-http-service-using-web-hosting) the provide, *but* when you come to the part about registering routes **IGNORE IT!** It'll tell you to just call this
```
ReportsControllerConfiguration.RegisterRoutes(GlobalConfiguration.Configuration);
```
However when Sitefinity tries to load it'll crash right away because some of the default route names conflict with Sitefinity. What we have to do instead is manually register them, and here it is! Just call this in Global.asax INSTEAD if the ReportsControllerConfiguration.RegisterRoutes that they provide.
RegisterReportingRoutes.csView on GitHub using System;
using System.Linq;
using System.Web.Http;
using System.Web.Routing;
using Telerik.Sitefinity.HealthMonitoring;
namespace SitefinityWebApp.App_Start
{
public class RegisterReportingRoutes
{
public static void RegisterRoutes()
{
using (var methodPerformanceRegion1 = new MethodPerformanceRegion("RegisterReportingRoutes"))
{
RouteTable.Routes.MapHttpRoute(
name: "GetClientsSessionTimeoutSeconds",
routeTemplate: "api/{controller}/clients/sessionTimeout",
defaults: new { action = "GetClientsSessionTimeoutSeconds" });
RouteTable.Routes.MapHttpRoute(
name: "TelerikReportingResources",
routeTemplate: "api/{controller}/resources/{folder}/{resourceName}",
defaults: new { action = "Resources" });
RouteTable.Routes.MapHttpRoute(
name: "Clients",
routeTemplate: "api/{controller}/clients/{clientID}",
defaults: new { action = "Clients", clientID = RouteParameter.Optional });
RouteTable.Routes.MapHttpRoute(
name: "Instances",
routeTemplate: "api/{controller}/clients/{clientID}/instances/{instanceID}",
defaults: new { action = "Instances", instanceID = RouteParameter.Optional });
RouteTable.Routes.MapHttpRoute(
name: "DocumentResources",
routeTemplate: "api/{controller}/clients/{clientID}/instances/{instanceID}/documents/{documentID}/resources/{resourceID}",
defaults: new { action = "DocumentResources" });
RouteTable.Routes.MapHttpRoute(
name: "DocumentActions",
routeTemplate: "api/{controller}/clients/{clientID}/instances/{instanceID}/documents/{documentID}/actions/{actionID}",
defaults: new { action = "DocumentActions" });
RouteTable.Routes.MapHttpRoute(
name: "DocumentPages",
routeTemplate: "api/{controller}/clients/{clientID}/instances/{instanceID}/documents/{documentID}/pages/{pageNumber}",
defaults: new { action = "DocumentPages" });
RouteTable.Routes.MapHttpRoute(
name: "DocumentInfo",
routeTemplate: "api/{controller}/clients/{clientID}/instances/{instanceID}/documents/{documentID}/info",
defaults: new { action = "DocumentInfo" });
RouteTable.Routes.MapHttpRoute(
name: "Documents",
routeTemplate: "api/{controller}/clients/{clientID}/instances/{instanceID}/documents/{documentID}",
defaults: new { action = "Documents", documentID = RouteParameter.Optional });
RouteTable.Routes.MapHttpRoute(
name: "Parameters",
routeTemplate: "api/{controller}/clients/{clientID}/parameters",
defaults: new { action = "Parameters" });
RouteTable.Routes.MapHttpRoute(
name: "ApiDefault",
routeTemplate: "api/{controller}/{action}");
}
}
}
}
So for the viewer part, here's the [setup doc](https://docs.telerik.com/reporting/html5-mvc-report-viewer-embedding) but I'll also post my Vue implementation here (still uses some jQuery, viewer requires it...). Feel free to use or not, and\or tweak. It is a shareable vue component I use inside other REPORTNAME.cshtml files like this
```
```
reportviewer-vue.component.jsView on GitHub Vue.component('telerikReportViewer', {
props: ['params'],
data: function () {
return {
viewer: null,
paths: $reportPaths
}
},
mounted: function () {
var $that = this;
console.log("Mounting Report");
this.createReport();
},
methods: {
createReport: function () {
var $that = this;
if ($that.viewer == null) {
console.log("Creating Viewer");
$that.viewer = $("#reportViewer1").telerik_ReportViewer({
serviceUrl: "/api/reports/",
templateUrl: $that.paths.template,
reportSource: {
report: $that.paths.source,
parameters: this.params
},
//Options
viewMode: telerikReportViewer.ViewModes.INTERACTIVE,
scaleMode: telerikReportViewer.ScaleModes.FIT_PAGE_WIDTH,
scale: 1.0//,
//persistSession: true
}).data("telerik_ReportViewer");
} else {
//Already initalized
console.log("Viewer Exists");
$that.viewer.reportSource({
report: $that.paths.source,
parameters: this.params
});
$that.viewer.refreshReport();
}
},
refreshReport: function () {
this.createReport();
}
},
template: '<div class="report-wrapper" style="min-height: 1000px"><div id="reportViewer1"></div></div>'
})
function nullifyValue(val) {
if (val == "")
return null;
else
return val;
}
ReportViewer.cshtmlView on GitHub @model string
@using Telerik.Sitefinity;
@using Telerik.Sitefinity.Model;
@using System.Web.Configuration;
@using Medportal.Sitefinity.Controls;
@using Telerik.Sitefinity.Frontend.Mvc.Helpers;
@{
var version = WebConfigurationManager.AppSettings["telerikReportingVersion"].ToString(); //Single source the version...
var cssPath = $"/include/ReportViewer/styles/telerikReportViewer.css?v={version}"; //cache bust with the version
var jsPath = $"/include/ReportViewer/js/telerikReportViewer-{version}.js"; //cache bust with the version
var templatePath = $"/include/ReportViewer/templates/telerikReportViewerTemplate-FA.html?v={version}"; //cache bust with the version
}
@Html.StyleSheet(cssPath, "plugins") //You need a "plugins" Html.Section defined in your layout
@Html.Script(jsPath, "plugins") //You need a "plugins" Html.Section defined in your layout
@Html.Script("/Mvc/Views/TelerikReporting/Resources/reportviewer-vue.component.js?v=" + Util.MpConfig.ScriptStylePostfix, "plugins")
<style>
#reportViewer1 {
position: absolute;
left: 5px;
right: 5px;
top: -1px;
bottom: 5px;
font-family: 'segoe ui', 'ms sans serif';
min-height: 1000px;
overflow: hidden;
}
</style>
<script>
var $reportPaths = {
template: '@templatePath',
source: '@Model'
}
</script>
# Use VueJs with Sitefinity
[VueJs](https://vuejs.org/) is by far my favorite javascript framework. The topic of "Can I use X with Sitefinity" also comes up a lot. The answer is always "yes" (because the framework you use on the front end doesn't matter). In pure mvc mode the page renders everything exactly where you tell it to go.
So this tutorial is for larger sites where one might have MANY pages and MANY vue components\widgets scattered around. You don't want a 4 meg vue webpacked file loaded on every page, you just want the components needed for that page. This is the fundamental problem with a CMS where you let users design whatever they want.
## Step 1: Load vue
You're going to want to open your /ResourcePackages//Mvc/Views/Layouts folder and open your main layout template... it's usually "default.cshtml" unless you've changed it.
default.cshtmlView on GitHub @using System.Web.Mvc;
@using Telerik.Sitefinity.Frontend.Mvc.Helpers;
@using Telerik.Sitefinity.Modules.Pages;
@using Telerik.Sitefinity.UI.MVC;
@using Telerik.Sitefinity.Services;
<!DOCTYPE html>
<!--[if IE 8]> <html class="no-js lt-ie9 ie8"> <![endif]-->
<!--[if gt IE 8]><!-->
<html class="no-js" @Html.RenderLangAttribute()>
<!--<![endif]-->
<head>
<title></title>
@Html.Section("head")
</head>
<body>
@Html.Section("top")
<div id="content" v-cloak>
<div>
{{ message }}
</div>
@Html.SfPlaceHolder("contentPlaceHolder")
</div>
@Html.Section("jquery")
@Html.Section("vue")
@Html.Section("kendo")
@Html.Section("plugins")
@Html.Section("bottom")
@Html.Partial("~/MVC/Views/Shared/Vue.cshtml")
@* This is the core sites vue script, minify or do whatever you need to it *@
@Html.Script("/ResourcePackages/YourTheme/assets/main-vue.js", "bottom")
</body>
</html>
Here's the core\main javascript file for the site, this is where all the custom code should be living for your "theme" like things that should exist on EVERY page (like menu stuff)
var $mySite = new Vue({
el: '#content',
name: "mySite",
data: {
message: "hello"
},
created: function () {
},
mounted: function () {
},
methods: {
},
computed: {
},
watch: {
}
});
So we're setting up the layout such that we can use Html.Script to precisely place the scripts where we need. Just because we have a jQuery section in here, doesn't mean anything nessesarily will go there, but like if we want to use kendo and it's vuejs wrappers, we're going to need Kendo AND jQuery (the wrappers are free, and kendo for jQuery is included with Sitefinity).
So you can see though the 2 frameworks are at the top, then plugins come after, then "bottom" is usually for custom scripts (or vue components) we're going to be making. Because we're not webpacking this badboy... sadly not going to be using Single File Components.
At the very bottom we're loading Vue from a shared cshtml file to keep things clean
@using Telerik.Sitefinity.Frontend.Mvc.Helpers;
@Html.Script(Util.VueJsScript, "vue")
@Html.Script("https://unpkg.com/axios@0.21.1/dist/axios.min.js", "plugins") @*OPTIONAL*@
You'll notice I'm loading it through a helper method
public static class Util
{
/// Return the Vue script itself, uses dev build on dev\staging so we get the VueJs browser tooling
public static string VueJsScript {
get
{
return Util.IsDev ? "https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.js" : "https://cdn.jsdelivr.net/npm/vue@2.6.12";
}
}
/// Is the site running on live or dev
public static bool IsDev {
get
{
var context = Telerik.Sitefinity.Services.SystemManager.CurrentHttpContext;
return context.Request.Url.Host.Contains("dev.") || context.Request.Url.Host.Contains("staging.");
}
}
}
This lets me get the [VueJs chrome extension](https://chrome.google.com/webstore/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd?hl=en) tooling working if the site is on dev or staging. For this purpose the scripts are hardcoded, for an actual site you should be storing them in a configuration file to be updated in the backend.
## Step 2: Create your widget
I'm not going to bother going through how to make a basic Sitefinity MVC Widget, lets just assume you already have the Controllers\Model\View files all setup and ready to go, and it's called "MyWidget".
MyWidget.cshtmlView on GitHub @model SitefinityWebApp.Mvc.Models.MyWidgetModel
@using Telerik.Sitefinity.Frontend.Mvc.Helpers
@Html.Script("/MVC/Views/MyWidget/Resources/component-mywidget.js", "bottom")
<mywidget></mywidget>
In here we're just rendering the vue component node itself, pass in whatever you need as properties, handle events as normal, it's up to you.
component-mywidget.jsView on GitHub Vue.component('mywidget', {
props: [],
data: function () {
return {
componentdata: "I'm the component"
}
},
mounted: function () {
},
methods: {
},
template: '<div class="component">{{ componentdata }}</div>'
})
The component is just an un-fancy .js filem but now it should load just fine. Dropping the MyWidget 10 times on a page doesn't load the script 10 times, but you should get 10 instances of that widget rendering.
## Step 3
That's it, there's no step 3, just drag\drop and use your sitefinity widget as normal. The main core vue script will handle loading the components inside of itself (and the v-cloak hides the flout)
## Enhancements\Notes
### These are things I couldn't really add because they increased complexity, but you probably should consider doing...
* Go through your Sitefinity theme template view files and move all the scripts from "top" or "head" into one of our placeholder elements we defined. This should provide a significant performance rendering bump as the scripts aren't loading\running ABOVE the content.
* I hate the inline templates in the .js files with no syntax highlighting, just a giant ugly javascript string. If a component is only to be used ONCE per page, you can change it to ``` template: #mywidget-template ``` then move that template into the cshtml as ```