Tuesday 16 December 2008

Using .Net Expression Builders to set control properties

In my last post I introduced my Language Store solution for multi-lingual SharePoint sites, and showed the two ways it can be used:

In standard .Net procedural code:

string sButtonText = LanguageStore.GetValue("Search", "SearchGoButtonText");

Declaratively in HTML:


<asp:Button runat="server" id="btnSearch" Text="<%$ SPLang:Search|SearchGoButtonText %>" />


This declarative syntax is very useful as it means the developer doesn't have to clutter up code-behind files just to call the method to retrieve a value, then assign it to the 'Text' property of various controls. I've also retrofitted this to my Config Store solution (along with some other enhancements) and this will be available on Codeplex soon. You might notice it's the same syntax as the SPUrl token which can be used in master pages/page layouts to get a relative path to an image or CSS file, and that's because I'm using the same .Net technique. Since I had to do some digging to work out how this was done, I'm guessing (could be wrong here!) many other developers haven't come across this either, so here's how it's done.

Implementing an expression builder class

An expression builder is essentially a class which derives from System.Web.Compilation.ExpressionBuilder and contains logic to evaluate an expression at page parse time. The 'secret' is that the ASP.Net page parsing engine understands that it needs to call the class's method whenever it encounters an expression in the appropriate form. These are the things that join this mini-framework together:

  • Class derived from ExpressionBuilder which overrides the EvaluateExpression() and GetCodeExpression() methods

  • Declaration in web.config which associates your prefix ('SPLang' in my case) with your expression builder class

  • Optional use of ExpressionPrefix attribute on class for designer support

  • An expression in declarative HTML (as per the example above)

Taking things step-by-step, here's what my class looks like:


[ExpressionPrefix("SPLangStore")]
public class LangStoreExpressionBuilder : ExpressionBuilder
{
private static TraceSwitch traceSwitch = new TraceSwitch("COB.SharePoint.Utilities.LanguageStore",
"Trace switch for Language Store");

private static LangStoreTraceHelper trace = new LangStoreTraceHelper("COB.SharePoint.Utilities.LangStoreExpressionBuilder");

public static object GetEvalData(string expression, Type target, string entry)
{
trace.WriteLineIf(traceSwitch.TraceVerbose, TraceLevel.Verbose, "GetEvalData(): Entered with expression '{0}'.",
expression);

string[] aExpressionParts = expression.Split('|');
string sCategory = aExpressionParts[0];
string sTitle = aExpressionParts[1];

if ((aExpressionParts.Length != 2) || (string.IsNullOrEmpty(sCategory) || string.IsNullOrEmpty(sTitle)))
{
trace.WriteLineIf(traceSwitch.TraceError, TraceLevel.Error, "GetEvalData(): Unable to parse expression '{0}' into " +
"format 'Category|Title' - throwing exception.",
expression);

throw new LanguageStoreConfigurationException("Token passed to Language Store expression builder was in the wrong format - " +
"expressions should be in form Language Store Category|Item Title e.g. Search|SearchGoButtonText");
}

string sValue = LanguageStore.GetValue(sCategory, sTitle);

trace.WriteLineIf(traceSwitch.TraceInfo, TraceLevel.Info, "GetEvalData(): Retrieved '{0}' from Language Store.",
sValue);

trace.WriteLineIf(traceSwitch.TraceVerbose, TraceLevel.Verbose, "GetEvalData(): Returning '{0}'.",
sValue);

return sValue;
}

public override object EvaluateExpression(object target, BoundPropertyEntry entry,
object parsedData, ExpressionBuilderContext context)
{
return GetEvalData(entry.Expression, target.GetType(), entry.Name);
}

public override CodeExpression GetCodeExpression(BoundPropertyEntry entry,
object parsedData, ExpressionBuilderContext context)
{
Type type1 = entry.DeclaringType;
PropertyDescriptor descriptor1 = TypeDescriptor.GetProperties(type1)[entry.PropertyInfo.Name];
CodeExpression[] expressionArray1 = new CodeExpression[3];
expressionArray1[0] = new CodePrimitiveExpression(entry.Expression.Trim());
expressionArray1[1] = new CodeTypeOfExpression(type1);
expressionArray1[2] = new CodePrimitiveExpression(entry.Name);
return new CodeCastExpression(descriptor1.PropertyType, new CodeMethodInvokeExpression(new
CodeTypeReferenceExpression(base.GetType()), "GetEvalData", expressionArray1));
}

public override bool SupportsEvaluate
{
get { return true; }
}
}


If you're wondering why two methods are required, it's because GetCodeExpression() is used where the page has been compiled and EvaluateExpression() is used when it is purely being parsed. My code follows the MSDN pattern which supports both modes and uses a third helper method (GetEvalData()) which both call into. It's this GetEvalData() method which does the work of parsing the passed expression and then using it to obtain the value - in my case the expression is the 'category' and 'title' of the item to fetch from the Language Store. Notice that effectively, the key line in all of that is one line in GetEvalData() which calls my existing LanguageStore.GetValue() method - so effectively my expression builder is just a wrapper for this method.

My web.config entry looks like this:


<add expressionPrefix="SPLang" type="COB.SharePoint.Utilities.LangStoreExpressionBuilder, COB.SharePoint.Utilities.LanguageStore, Version=1.0.0.0, Culture=neutral, PublicKeyToken=23afbf06fd91fa64" />

And finally here's how the component parts of the expression get used:

ExpressionBuilderSyntax

For SharePoint solutions, assuming we're deploying our code as a Feature/Solution we'd generally want to add the web.config entry automatically by way of the SPWebConfigModification class. You can find the code to do this in on Codeplex in the source code for my Language Store solution (in the Feature receiver).

Finally, if you're building an expression builder and this information doesn't get you all the way, the MSDN documentation for the ExpressionBuilder class has some additional details.

Enhancing the design-time experience with a custom expression editor

This is something I haven't looked at yet, but looks extremely cool! If the standard expression builder stuff wasn't convenient enough for you, you can extend things further by providing a custom 'ExpressionEditor' for use in Visual Studio. If I understand the possibility correctly, this can provide a better experience in the VS properties grid in two ways:

  • a custom editor sheet (e.g. a dialog to enter the 'category' and 'title' of the Language Store item - let's say 'Search' and 'SearchGoButtonText' was entered respectively, this would 'build' the string in the correct delimited 'Search|SearchGoButtonText' form required)

  • a custom picker using the Expressions collection - this could (I think) be used to query the Language Store list and display all the items, so that selecting the item to display the translation of is as simple as a few clicks, no typing!

I'd absolutely love to implement this for the Language Store/Config Store - so I might return to this at a later date!

Conclusion

Expression builders provide a powerful, clean way to inject method calls into your markup. In most cases we're used to seeing them return strings as in my Language Store/Config Store implementations, and note that the following implementations are also present in the .Net framework:

However, one final thing to bear in mind is that the signature of the method returns an object - so theoretically it should be possible to do a whole host of other things, where the processsing returns a more complex object which gets assigned to the control property. An example could be data-binding scenarios where your method returns something which implements IEnumerable/IList - this could then be assigned to the DataSource property of your control declaratively. You might have other possibilities in mind, but hopefully that's food for thought ;-)

Monday 8 December 2008

Building multi-lingual SharePoint sites - introducing the Language Store

If you're ever asked to build a multi-lingual site in SharePoint, it quickly becomes apparent that there are a few extra considerations compared to a single language site. These could include:

  • Information architecture
  • Language/culture detection
  • Deciding whether to use variations or not
  • URL strategy

..and so on. Clearly these are decisions which will have a different 'answer' for every multi-lingual project, and typically your client's specific requirements will steer your approach. However one challenge which is likely to remain constant across most such projects is this one:

  • How to deal with the many small strings of text which are not part of authored page content which need to be translated and displayed in the appropriate language

This is the challenge I'm focusing on here. To illustrate, here's an example from the BBC site where I've highlighted all the strings which may need to be translated but which don't belong to a particular page:

BBCExample

..and that's just one page - it turns out a typical site will have many of these. If you have to translate additional strings shown to authors in edit mode only, you could easily find the total number stretching into the hundreds. So we start to need a framework for storage/retrieval of these 'page furniture' items. If we were dealing with a shrink-wrapped product, .Net resource files could be a good choice, but this approach is probably not flexible enough for a website and won't allow content authors/power users to enter translations. Clearly something based around a SharePoint list is called for, so enter the 'Language Store' - my solution to the problem which you can now download from Codeplex (link at the end).

Introducing the Language Store

The Language Store is an adaptation of my earlier Config Store solution and follows some of the same principles:-

  • values are stored in a SharePoint list
  • an API is provided to retrieve values with a single method call
  • a caching framework is used for optimum performance
  • easily deployed as a .wsp

Items in the list are categorized, and have a column for each language translation:

LanguageStoreListNarrow

Note that each translation column in the list is named with the convention 'LANG_<culture name>' (N.B. you might know a 'culture name' as a 'locale ID' or similar) - so when a new language needs to be added to the site, you simply create a new column with the appropriate name and add the translations. A list of culture names can be found in the MSDN documentation for the CultureInfo class.

UPDATED - the 'National Language Support' API reference page on MSDN is a better reference - think the CultureInfo page has changed since I linked to it.

Retrieving values

To retrieve a value, we simply call the GetValue() method and pass the category and title of the item to retrieve:

string sButtonText = LanguageStore.GetValue("Search", "SearchGoButtonText");

Also, since many of the items we might put in the Language Store are only used in the presentation of the page, it's often a shame to have to switch to the code-behind just to fetch these values and assign them to a control's 'Text' property. So I've provided a tokenized method similar to SPUrl, which allows you to simply drop Language Store values into your markup like this:


<asp:Button runat="server" id="btnSearch" Text="<%$ SPLangStore:Search|SearchGoButtonText %>" />

I like this because it means you don't end up cluttering your code-behind with lots of lines just for fetching values from the Language Store and assigning them to ASP.Net labels or controls. For those who don't know how this is done I'll write more about it in the next post as I think it's a cool, under-used facility in .Net.

How the Language Store determines which language to retrieve

In the current implementation, the regional settings of the SPWeb are used to determine which translations are retrieved. It's a single method in the code (a single line in fact!), so this scheme could easily be changed if you have a different requirement. We're using the Language Store on our current project, and using the SPWeb setting makes sense for us since we're building around 100 different sites in ~30 languages, as opposed to one site which displays in the local language (according to the user's thread culture or similar).

Note that if the Language Store doesn't contain a value for the requested culture, a fallback process is used similar to .Net's globalization framework:

  1. Check preferred culture  e.g. fr-CH for French (Switzerland)
  2. Check preferred culture's parent e.g. fr for French
  3. Check default language (determined by configuration) e.g. en for English

This is useful where some items might have a different version for say American English (EN-US) and British English (EN-GB), but other items don't require distinction so a single value can be entered into the parent column (EN).

By the way, if you're wondering where the SPWeb regional settings are because you've never needed to change them, they're here:

RegionalSettingsLink

RegionalSettingsPage

A checkbox allows the regional settings you make on a given web cascade down to child webs, so we simply set this at the root of the site as a one time operation.


Other bits and pieces
  • All items are wrapped up in a Solution/Feature so there is no need to manually create site columns/content types/the Language Store list etc. There is also an install script for you to easily install the Solution.
  • Caching implementation is currently based around a CacheDependency on a file - this enables the cache on all servers in your farm to be invalidated when an item is updated, but does require that all WFEs can write to this location (e.g. firewalls are not in the way).
  • The Language Store can also be used where no SPContext is present e.g. a list event receiver. In this scenario, it will look for values in your SharePoint web application's web.config file to establish the URL for the site containing the Language Store (N.B. these web.config keys get automatically added when the Language Store is installed to your site). This also means it can be used outside your SharePoint application, e.g. a console app.
  • The Language Store can be moved from it's default location of the root web for your site (to do this, create a new list (in whatever child web you want) from the 'Language Store list' template (added during the install), and modify the 'LanguageStoreWebName'/'LanguageStoreListName' keys which were added to your web.config to point to the new location. As an alternative if you already added 100 items which you don't want to recreate, you could use my other tool, the SharePoint Content Deployment Wizard at http://www.codeplex.com/SPDeploymentWizard to move the list.)
  • All source code and Solution/Feature files are included, so if you want to change anything, you can.
  • Installation instructions are in the readme.txt in the download.

You can download the Language Store and all source code from www.codeplex.com/SPLanguageStore. All feedback welcome!